Compare commits

...

43 Commits

Author SHA1 Message Date
Chris Chen 3a121f6085 fix(account): add Account Settings to real sidebar nav
The Settings item wired in Task 7 lived in UserHeaderComponent, which is
unused dead code (its selector is rendered nowhere). Add a real "Account
Settings" entry to the Personal nav section of UserPortalComponent (the
actual shell) pointing at /user-portal/account, and revert the ineffective
user-header edit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:26:17 -07:00
Chris Chen 5a25b33258 fix(account): show new!=current error under New Password field
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:21:52 -07:00
Chris Chen b0deb62c82 update sunday 2026-06-23 20:20:12 -07:00
Chris Chen a2ecc895de feat(account): add Account Settings page, route, and wire Settings menu item 2026-06-23 20:15:56 -07:00
Chris Chen 1e6ddddf1f feat(account): add ChangePasswordFormComponent
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:10:58 -07:00
Chris Chen c54adf1eda feat(auth): add changePassword() to frontend AuthService 2026-06-23 20:04:18 -07:00
Chris Chen 5e0348de1d feat(account): add password strength and match validators 2026-06-23 20:01:16 -07:00
Chris Chen 8f18166dbf feat(auth): add POST /api/auth/change-password endpoint 2026-06-23 19:54:20 -07:00
Chris Chen 8f1af536ed fix(auth): make change-password session revocation null-safe for Npgsql 2026-06-23 19:52:21 -07:00
Chris Chen 180dea60c1 feat(auth): add ChangePasswordAsync with other-session revocation and audit 2026-06-23 19:47:43 -07:00
Chris Chen 9df391b42c feat(auth): add PasswordChanged audit action and ChangePasswordRequest DTO 2026-06-23 19:44:23 -07:00
Chris Chen 4225b49e58 Merge feature/notification-service: Email + Line notification service (API)
ci-cd-vm / ci-cd (push) Successful in 2m29s
2026-06-23 19:37:35 -07:00
Chris Chen 5a915ebdd1 Harden notifications: bump MailKit, bound webhook body, share truncation, skip soft-deleted members 2026-06-23 19:29:23 -07:00
Chris Chen fd71f5a107 Cross-link implemented notification design in NOTIFICATIONS.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:23:22 -07:00
Chris Chen 9405914d88 Register notification services and add SMTP/Line config sections
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 19:21:47 -07:00
Chris Chen 39432ac588 Add admin NotificationsController for binding, groups, history, and send 2026-06-23 19:20:28 -07:00
Chris Chen 4c22cfaf19 Add Line webhook controller with signature verification and dispatch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 19:18:50 -07:00
Chris Chen c8bc7103ba Add LineNotificationService with send, binding, and group ops
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 19:17:10 -07:00
Chris Chen 3eeb314dc2 Add IMessageChannel and Line REST implementation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 19:13:42 -07:00
Chris Chen 0ddb34dd20 Add EmailService with recipient resolution and logging
TDD: IEmailService interface, EmailService resolves member emails + raw addresses (case-insensitive dedup), sends via ISmtpDispatcher, writes a NotificationLog per recipient (sent/failed), and never aborts the batch on a single failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:11:13 -07:00
Chris Chen 444cc70b56 Add SMTP dispatcher seam and MailKit implementation 2026-06-23 19:08:30 -07:00
Chris Chen 85bf329d93 Add Line webhook signature verification helper
Implements LineSignature.IsValid() using HMAC-SHA256 + FixedTimeEquals to prevent timing attacks; includes xUnit tests for valid, tampered, and null/empty header cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:07:01 -07:00
Chris Chen 3544b6ee78 Add change-password implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:04:09 -07:00
Chris Chen 0e90f19377 Add notification entities, DbContext config, and migration
Creates MemberChannelBinding, LineBindingCode, MessagingGroup, and NotificationLog
entities under ROLAC.API.Entities.Notifications; wires DbSets and fluent config into
AppDbContext; generates EF migration AddNotifications creating the four tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:03:35 -07:00
Chris Chen f9c4d7edb2 Add shared notification models, records, and constants 2026-06-23 19:00:24 -07:00
Chris Chen b7372dec1f Add MailKit package and notification option classes 2026-06-23 18:58:41 -07:00
Chris Chen 21e9823008 Add self-service change-password design spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:58:03 -07:00
Chris Chen 583408032d Add implementation plan for Email + Line notification service
12 TDD tasks: MailKit package, entities + migration, email service (SMTP seam),
Line message channel + signature verify, Line notification service (send/binding/
groups), webhook + admin controllers, DI + config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:56:04 -07:00
Chris Chen ea0ea233a8 Add Email + Line notification service design spec
Phase 1 (API-only): IEmailService (MailKit/SMTP) + ILineNotificationService
(full approved Line module) as two peer services sharing NotificationLog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:46:44 -07:00
Chris Chen 7356d0e810 Update attendance-counter-page.component.scss
ci-cd-vm / ci-cd (push) Failing after 1m25s
2026-06-23 17:47:16 -07:00
Chris Chen b1e3e23325 Sunday Worship Count
ci-cd-vm / ci-cd (push) Failing after 1m28s
2026-06-23 17:24:12 -07:00
Chris Chen a298d0ee1c update for signalR
ci-cd-vm / ci-cd (push) Successful in 44s
2026-06-23 17:12:17 -07:00
Chris Chen 249ae1164d Update rolac.conf
ci-cd-vm / ci-cd (push) Successful in 44s
2026-06-23 17:03:20 -07:00
Chris Chen c6e3f1db64 Revert "Update rolac.conf"
ci-cd-vm / ci-cd (push) Successful in 39s
This reverts commit bd722933dc.
2026-06-23 16:51:43 -07:00
Chris Chen bd722933dc Update rolac.conf
ci-cd-vm / ci-cd (push) Successful in 46s
2026-06-23 16:10:14 -07:00
Chris Chen f6277aa339 Update angular.json
ci-cd-vm / ci-cd (push) Successful in 1m45s
2026-06-23 15:16:19 -07:00
Chris Chen 2e226e60f5 update mobile view.
ci-cd-vm / ci-cd (push) Failing after 1m13s
2026-06-23 14:18:55 -07:00
Chris Chen 68649223d9 update mobile view. 2026-06-23 14:15:20 -07:00
Chris Chen 9d7c224ad2 Update user-portal.component.scss 2026-06-23 13:53:57 -07:00
Chris Chen 47aec287aa update mobile view for expense. 2026-06-23 13:49:38 -07:00
Chris Chen 5dfca873dd update for shrink image size.
ci-cd-vm / ci-cd (push) Successful in 1m23s
2026-06-23 13:30:20 -07:00
Chris Chen 62592c29ae Add audit logs.
ci-cd-vm / ci-cd (push) Successful in 4m2s
2026-06-23 12:13:47 -07:00
Chris Chen 870eeec82a Add role control 2026-06-23 07:19:08 -07:00
204 changed files with 12622 additions and 604 deletions
+33
View File
@@ -50,3 +50,36 @@ jobs:
docker compose up -d
sleep 5
curl -fsS http://localhost:8080/api/health
# Always runs (success or failure) so the team gets a build result in Rocket.Chat.
- name: Notify Rocket.Chat
if: always()
env:
JOB_STATUS: ${{ job.status }}
REPO: ${{ github.repository }}
REF: ${{ github.ref_name }}
SHA: ${{ github.sha }}
ACTOR: ${{ github.actor }}
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}
WEBHOOK: ${{ secrets.ROCKETCHAT_WEBHOOK }}
run: |
if [ "$JOB_STATUS" = "success" ]; then
STATUS_TEXT="✅ Build succeeded"
COLOR="#2ecc71"
else
STATUS_TEXT="❌ Build failed"
COLOR="#e74c3c"
fi
SHORT_SHA="${SHA:0:7}"
curl -fsS -X POST -H 'Content-Type: application/json' --data @- "$WEBHOOK" <<JSON
{
"attachments": [
{
"title": "$REPO — $STATUS_TEXT",
"title_link": "$COMMIT_URL",
"color": "$COLOR",
"text": "Branch *$REF* · commit $SHORT_SHA · by $ACTOR"
}
]
}
JSON
+38
View File
@@ -52,3 +52,41 @@ jobs:
docker compose pull
docker compose up -d
curl -fsS https://manage.rolac.org/api/health
# Always runs (success or failure) so the team gets a build result in Rocket.Chat.
# A failed or skipped upstream job (skipped means an earlier job failed) reports as failed.
notify:
needs: [test, build-push, deploy]
if: always()
runs-on: ubuntu-latest
steps:
- name: Notify Rocket.Chat
env:
BUILD_FAILED: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }}
REPO: ${{ github.repository }}
REF: ${{ github.ref_name }}
SHA: ${{ github.sha }}
ACTOR: ${{ github.actor }}
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}
WEBHOOK: ${{ secrets.ROCKETCHAT_WEBHOOK }}
run: |
if [ "$BUILD_FAILED" = "true" ]; then
STATUS_TEXT="❌ Build failed"
COLOR="#e74c3c"
else
STATUS_TEXT="✅ Build succeeded"
COLOR="#2ecc71"
fi
SHORT_SHA="${SHA:0:7}"
curl -fsS -X POST -H 'Content-Type: application/json' --data @- "$WEBHOOK" <<JSON
{
"attachments": [
{
"title": "$REPO — $STATUS_TEXT",
"title_link": "$COMMIT_URL",
"color": "$COLOR",
"text": "Branch *$REF* · commit $SHORT_SHA · by $ACTOR"
}
]
}
JSON
@@ -0,0 +1,64 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Moq;
using ROLAC.API.Authorization;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Authorization;
public class PermissionAuthorizationHandlerTests
{
private static ClaimsPrincipal UserWithRoles(params string[] roles)
{
var claims = roles.Select(role => new Claim("role", role));
return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: "test"));
}
private static async Task<bool> EvaluateAsync(
ClaimsPrincipal user, PermissionRequirement requirement, IPermissionService permissions)
{
var handler = new PermissionAuthorizationHandler(permissions);
var context = new AuthorizationHandlerContext([requirement], user, resource: null);
await handler.HandleAsync(context);
return context.HasSucceeded;
}
[Fact]
public async Task SuperAdmin_AlwaysSucceeds_WithoutConsultingMatrix()
{
var permissions = new Mock<IPermissionService>(MockBehavior.Strict); // must NOT be called
var requirement = new PermissionRequirement(Modules.Members, PermissionActions.Delete);
var succeeded = await EvaluateAsync(UserWithRoles("super_admin"), requirement, permissions.Object);
Assert.True(succeeded);
permissions.Verify(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task RoleWithPermission_Succeeds()
{
var permissions = new Mock<IPermissionService>();
permissions.Setup(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), Modules.Members, PermissionActions.Write))
.ReturnsAsync(true);
var requirement = new PermissionRequirement(Modules.Members, PermissionActions.Write);
var succeeded = await EvaluateAsync(UserWithRoles("secretary"), requirement, permissions.Object);
Assert.True(succeeded);
}
[Fact]
public async Task RoleWithoutPermission_Fails()
{
var permissions = new Mock<IPermissionService>();
permissions.Setup(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(false);
var requirement = new PermissionRequirement(Modules.Givings, PermissionActions.Write);
var succeeded = await EvaluateAsync(UserWithRoles("member"), requirement, permissions.Object);
Assert.False(succeeded);
}
}
@@ -26,7 +26,7 @@ public class AuditInterceptorTests
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AuditSaveChangesInterceptor(mock.Object);
return new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object));
}
[Fact]
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Auth;
using ROLAC.API.DTOs.Permissions;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
@@ -31,9 +32,10 @@ public class AuthServiceTests
/// <summary>Creates a <see cref="UserManager{TUser}"/> mock with sensible defaults.</summary>
private static Mock<UserManager<AppUser>> BuildUserManager(
AppUser? findResult = null,
bool passwordOk = true,
IList<string>? roles = null)
AppUser? findResult = null,
bool passwordOk = true,
IList<string>? roles = null,
IdentityResult? changePasswordResult = null)
{
var store = new Mock<IUserStore<AppUser>>();
// Remaining ctor params are all optional; Moq passes them via reflection.
@@ -52,6 +54,9 @@ public class AuthServiceTests
.ReturnsAsync(roles ?? new List<string> { "member" });
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
.ReturnsAsync(IdentityResult.Success);
mgr.Setup(m => m.ChangePasswordAsync(
It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(changePasswordResult ?? IdentityResult.Success);
return mgr;
}
@@ -72,11 +77,21 @@ public class AuthServiceTests
return svc;
}
/// <summary>IPermissionService mock: returns an empty effective-permission map.</summary>
private static Mock<IPermissionService> BuildPermissionService()
{
var svc = new Mock<IPermissionService>();
svc.Setup(p => p.GetEffectivePermissionsAsync(It.IsAny<IEnumerable<string>>()))
.ReturnsAsync(new Dictionary<string, ModuleActions>());
return svc;
}
private static AuthService BuildSut(
Mock<UserManager<AppUser>> umMock,
Mock<ITokenService> tsMock,
AppDbContext db)
=> new(umMock.Object, tsMock.Object, db, BuildConfig());
=> new(umMock.Object, tsMock.Object, db, BuildPermissionService().Object,
ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance, BuildConfig());
// -----------------------------------------------------------------------
// Login tests
@@ -255,4 +270,85 @@ public class AuthServiceTests
var token = db.RefreshTokens.Single();
Assert.NotNull(token.RevokedAt);
}
// -----------------------------------------------------------------------
// Change password tests
// -----------------------------------------------------------------------
[Fact]
public async Task ChangePassword_ValidRequest_Succeeds()
{
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
var um = BuildUserManager(findResult: user);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
var result = await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", null);
Assert.True(result.Succeeded);
um.Verify(m => m.ChangePasswordAsync(user, "Old1234!", "New1234!"), Times.Once);
}
[Fact]
public async Task ChangePassword_UnknownUser_Fails()
{
var um = BuildUserManager(findResult: null);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
var result = await sut.ChangePasswordAsync("missing", "Old1234!", "New1234!", null);
Assert.False(result.Succeeded);
um.Verify(m => m.ChangePasswordAsync(
It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task ChangePassword_WrongCurrentPassword_ReturnsFailure()
{
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
var failed = IdentityResult.Failed(new IdentityError { Description = "Incorrect password." });
var um = BuildUserManager(findResult: user, changePasswordResult: failed);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
var result = await sut.ChangePasswordAsync("u1", "WrongOld!", "New1234!", null);
Assert.False(result.Succeeded);
}
[Fact]
public async Task ChangePassword_Success_RevokesOtherSessionsButKeepsCurrent()
{
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
var um = BuildUserManager(findResult: user);
var ts = BuildTokenService(); // HashToken(x) => "hash:{x}"
var db = BuildDb();
// Current session token (raw "current-raw" => "hash:current-raw")
db.RefreshTokens.Add(new RefreshToken
{
UserId = "u1",
TokenHash = "hash:current-raw",
ExpiresAt = DateTime.UtcNow.AddDays(30),
CreatedAt = DateTime.UtcNow.AddHours(-1),
});
// Another active session on a different device
db.RefreshTokens.Add(new RefreshToken
{
UserId = "u1",
TokenHash = "hash:other-device",
ExpiresAt = DateTime.UtcNow.AddDays(30),
CreatedAt = DateTime.UtcNow.AddHours(-2),
});
await db.SaveChangesAsync();
var sut = BuildSut(um, ts, db);
await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", "current-raw");
var current = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:current-raw");
var other = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:other-device");
Assert.Null(current.RevokedAt); // current session preserved
Assert.NotNull(other.RevokedAt); // other session revoked
}
}
@@ -49,7 +49,7 @@ public class DisbursementServiceTests
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options);
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
}
private static DisbursementService SvcAs(AppDbContext db, FakeStorage fs, string userId)
@@ -57,7 +57,7 @@ public class DisbursementServiceTests
var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx);
return new DisbursementService(db, http.Object, fs, new FakePrint());
return new DisbursementService(db, http.Object, fs, new FakePrint(), ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
}
private static (DisbursementService svc, AppDbContext db, FakeStorage fs) Build(string userId = "fin")
@@ -203,7 +203,7 @@ public class DisbursementServiceTests
var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx);
return (new DisbursementService(db, http.Object, fs, print), db, fs, print);
return (new DisbursementService(db, http.Object, fs, print, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance), db, fs, print);
}
[Fact]
@@ -20,7 +20,7 @@ public class ExpenseCategoryServiceTests
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options);
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
}
[Fact]
@@ -33,7 +33,7 @@ public class ExpenseServiceTests
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options);
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
}
private static (ExpenseService svc, AppDbContext db, FakeStorage fs) Build(string userId = "u1")
@@ -52,7 +52,7 @@ public class ExpenseServiceTests
var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx);
return new ExpenseService(db, http.Object, fs);
return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
}
// Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier),
@@ -62,7 +62,7 @@ public class ExpenseServiceTests
var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim("sub", userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx);
return new ExpenseService(db, http.Object, fs);
return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
}
private static CreateExpenseRequest Reimb() => new()
@@ -197,6 +197,48 @@ public class ExpenseServiceTests
bobSvc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false));
}
[Fact]
public async Task Update_OwnPendingApproval_AsNonFinance_Succeeds()
{
// After Submit a reimbursement sits in PendingApproval; the owner may still correct it.
var (svc, db, _) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
var edit = CloneToUpdate(Reimb());
edit.Amount = 99.99m;
await svc.UpdateAsync(id, edit, isFinance: false);
var e = await db.Expenses.FindAsync(id);
Assert.Equal(99.99m, e!.Amount);
Assert.Equal("PendingApproval", e.Status);
}
[Fact]
public async Task Update_OwnApproved_AsNonFinance_Throws()
{
// Once approved, the owner can no longer edit.
var (svc, db, fs) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
await SvcAs(db, fs, "finance").ApproveAsync(id);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false));
}
[Fact]
public async Task SaveReceipt_OwnPendingApproval_AsNonFinance_Succeeds()
{
var (svc, db, _) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
using var input = new MemoryStream(Encoding.UTF8.GetBytes("img"));
await svc.SaveReceiptAsync(id, input, "r.jpg", isFinance: false);
Assert.NotNull(await svc.OpenReceiptAsync(id, isFinance: true));
}
[Fact]
public async Task SoftDelete_HidesFromQueries()
{
@@ -23,7 +23,7 @@ public class GivingCategoryServiceTests
private static AppDbContext BuildDb(string userId = "test-user")
{
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId));
var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId)));
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
@@ -24,7 +24,7 @@ public class GivingServiceTests
private static AppDbContext BuildDb(string userId = "test-user")
{
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId));
var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId)));
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
@@ -29,7 +29,7 @@ public class MemberServiceTests
private static AppDbContext BuildDb(string userId = "test-user")
{
var accessor = BuildAccessor(userId);
var interceptor = new AuditSaveChangesInterceptor(accessor);
var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(accessor));
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
@@ -20,7 +20,7 @@ public class MinistryServiceTests
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options);
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
}
[Fact]
@@ -21,7 +21,7 @@ public class MonthlyStatementServiceTests
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options);
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
}
private static MonthlyStatementService Build(AppDbContext db)
@@ -29,7 +29,7 @@ public class MonthlyStatementServiceTests
var mock = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) };
mock.Setup(x => x.HttpContext).Returns(ctx);
return new MonthlyStatementService(db, mock.Object);
return new MonthlyStatementService(db, mock.Object, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
}
[Fact]
@@ -0,0 +1,112 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Notifications;
using Xunit;
namespace ROLAC.API.Tests.Services.Notifications;
public class EmailServiceTests
{
// Records every email it is asked to send; can be told to throw for a given address.
private sealed class FakeSmtpDispatcher : ISmtpDispatcher
{
public List<OutboundEmail> Sent { get; } = new();
public string? FailForAddress { get; set; }
public Task SendAsync(OutboundEmail email, CancellationToken ct = default)
{
if (email.ToAddress == FailForAddress)
throw new InvalidOperationException("smtp rejected");
Sent.Add(email);
return Task.CompletedTask;
}
}
private static CurrentUserAccessor BuildAccessor(string userId = "test-user")
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new CurrentUserAccessor(mock.Object);
}
private static AppDbContext BuildDb()
{
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor());
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(interceptor)
.Options);
}
private static async Task<int> SeedMemberAsync(AppDbContext db, string? email)
{
var member = new Member { FirstName_en = "Test", LastName_en = "User", Email = email };
db.Members.Add(member);
await db.SaveChangesAsync();
return member.Id;
}
[Fact]
public async Task SendAsync_ResolvesMemberEmails_MergesRawAddresses_AndDedupes()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db, "member@example.com");
var dispatcher = new FakeSmtpDispatcher();
var service = new EmailService(db, dispatcher, BuildAccessor());
var message = new EmailMessage(
MemberIds: new[] { memberId },
Addresses: new[] { "extra@example.com", "member@example.com" }, // dup of member email
Subject: "Hi", HtmlBody: "<p>Body</p>");
var result = await service.SendAsync(message);
Assert.Equal(2, result.SentCount); // member@ + extra@, dup dropped
Assert.Equal(0, result.FailedCount);
Assert.Equal(2, dispatcher.Sent.Count);
Assert.Equal(2, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Sent));
}
[Fact]
public async Task SendAsync_SkipsMembersWithNoEmail()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db, null);
var dispatcher = new FakeSmtpDispatcher();
var service = new EmailService(db, dispatcher, BuildAccessor());
var result = await service.SendAsync(new EmailMessage(
new[] { memberId }, Array.Empty<string>(), "Hi", "<p>Body</p>"));
Assert.Equal(0, result.SentCount);
Assert.Empty(dispatcher.Sent);
}
[Fact]
public async Task SendAsync_LogsFailure_WithoutAbortingBatch()
{
using var db = BuildDb();
var dispatcher = new FakeSmtpDispatcher { FailForAddress = "bad@example.com" };
var service = new EmailService(db, dispatcher, BuildAccessor());
var result = await service.SendAsync(new EmailMessage(
Array.Empty<int>(),
new[] { "bad@example.com", "good@example.com" },
"Hi", "<p>Body</p>"));
Assert.Equal(1, result.SentCount);
Assert.Equal(1, result.FailedCount);
Assert.Single(result.Failures);
Assert.Equal("bad@example.com", result.Failures[0].Target);
Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed));
}
}
@@ -0,0 +1,77 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Options;
using ROLAC.API.Services.Notifications;
using Xunit;
namespace ROLAC.API.Tests.Services.Notifications;
public class LineMessageChannelTests
{
// Captures the outgoing request and returns a canned response.
private sealed class CapturingHandler : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
public string? LastBody { get; private set; }
public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
public string ResponseBody { get; set; } = "{}";
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
LastBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(StatusCode) { Content = new StringContent(ResponseBody) };
}
}
private static LineMessageChannel BuildChannel(CapturingHandler handler)
{
var http = new HttpClient(handler);
var options = Options.Create(new LineOptions { ChannelAccessToken = "tok", ChannelSecret = "sec" });
return new LineMessageChannel(http, options);
}
[Fact]
public async Task PushToUserAsync_PostsTextMessage_WithBearerToken()
{
var handler = new CapturingHandler();
var channel = BuildChannel(handler);
var result = await channel.PushToUserAsync("U123", "hello");
Assert.True(result.Success);
Assert.Equal("https://api.line.me/v2/bot/message/push", handler.LastRequest!.RequestUri!.ToString());
Assert.Equal("Bearer", handler.LastRequest.Headers.Authorization!.Scheme);
Assert.Equal("tok", handler.LastRequest.Headers.Authorization.Parameter);
using var doc = JsonDocument.Parse(handler.LastBody!);
Assert.Equal("U123", doc.RootElement.GetProperty("to").GetString());
Assert.Equal("hello", doc.RootElement.GetProperty("messages")[0].GetProperty("text").GetString());
}
[Fact]
public async Task ReplyAsync_PostsToReplyEndpoint_WithReplyToken()
{
var handler = new CapturingHandler();
var channel = BuildChannel(handler);
await channel.ReplyAsync("RTOKEN", "hi back");
Assert.Equal("https://api.line.me/v2/bot/message/reply", handler.LastRequest!.RequestUri!.ToString());
using var doc = JsonDocument.Parse(handler.LastBody!);
Assert.Equal("RTOKEN", doc.RootElement.GetProperty("replyToken").GetString());
}
[Fact]
public async Task PushToUserAsync_ReturnsFailure_OnNonSuccessStatus()
{
var handler = new CapturingHandler { StatusCode = HttpStatusCode.TooManyRequests, ResponseBody = "quota" };
var channel = BuildChannel(handler);
var result = await channel.PushToUserAsync("U123", "hello");
Assert.False(result.Success);
Assert.Contains("429", result.Error);
}
}
@@ -0,0 +1,211 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using ROLAC.API.Entities.Notifications;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Notifications;
using Xunit;
namespace ROLAC.API.Tests.Services.Notifications;
public class LineNotificationServiceTests
{
// Records pushes; can be told to fail every call.
private sealed class FakeMessageChannel : IMessageChannel
{
public List<(string Target, string Text)> UserPushes { get; } = new();
public List<(string Target, string Text)> GroupPushes { get; } = new();
public bool Fail { get; set; }
public Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default)
{
UserPushes.Add((externalId, text));
return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null));
}
public Task<MessageSendResult> PushToGroupAsync(string externalId, string text, CancellationToken ct = default)
{
GroupPushes.Add((externalId, text));
return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null));
}
public Task<MessageSendResult> ReplyAsync(string replyToken, string text, CancellationToken ct = default)
=> Task.FromResult(new MessageSendResult(true, null));
}
private static CurrentUserAccessor BuildAccessor(string userId = "test-user")
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new CurrentUserAccessor(mock.Object);
}
private static AppDbContext BuildDb()
{
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor());
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(interceptor)
.Options);
}
private static async Task<int> SeedMemberAsync(AppDbContext db)
{
var member = new Member { FirstName_en = "Test", LastName_en = "User" };
db.Members.Add(member);
await db.SaveChangesAsync();
return member.Id;
}
[Fact]
public async Task GenerateLineBindingCodeAsync_PersistsUnconsumedCode()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
var service = new LineNotificationService(db, new FakeMessageChannel());
var code = await service.GenerateLineBindingCodeAsync(memberId);
var stored = await db.LineBindingCodes.SingleAsync();
Assert.Equal(code, stored.Code);
Assert.Null(stored.ConsumedAt);
Assert.True(stored.ExpiresAt > DateTime.UtcNow);
}
[Fact]
public async Task TryBindMemberAsync_BindsMember_AndConsumesCode()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
var service = new LineNotificationService(db, new FakeMessageChannel());
var code = await service.GenerateLineBindingCodeAsync(memberId);
var result = await service.TryBindMemberAsync("U999", code);
Assert.True(result.Success);
Assert.Equal(memberId, result.MemberId);
var binding = await db.MemberChannelBindings.SingleAsync();
Assert.Equal("U999", binding.ExternalId);
Assert.NotNull((await db.LineBindingCodes.SingleAsync()).ConsumedAt);
}
[Fact]
public async Task TryBindMemberAsync_Fails_ForExpiredOrUsedOrUnknownCode()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
db.LineBindingCodes.Add(new LineBindingCode
{
Code = "EXPIRE", MemberId = memberId, ExpiresAt = DateTime.UtcNow.AddMinutes(-1),
});
await db.SaveChangesAsync();
var service = new LineNotificationService(db, new FakeMessageChannel());
Assert.False((await service.TryBindMemberAsync("U1", "EXPIRE")).Success); // expired
Assert.False((await service.TryBindMemberAsync("U1", "NOPE")).Success); // unknown
Assert.Empty(await db.MemberChannelBindings.ToListAsync());
}
[Fact]
public async Task TryBindMemberAsync_Rebinds_UpdatesExistingBinding()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
var service = new LineNotificationService(db, new FakeMessageChannel());
await service.TryBindMemberAsync("U-OLD", await service.GenerateLineBindingCodeAsync(memberId));
await service.TryBindMemberAsync("U-NEW", await service.GenerateLineBindingCodeAsync(memberId));
var binding = await db.MemberChannelBindings.SingleAsync();
Assert.Equal("U-NEW", binding.ExternalId);
}
[Fact]
public async Task RegisterGroupAsync_IsIdempotent_AndDeactivateFlips()
{
using var db = BuildDb();
var service = new LineNotificationService(db, new FakeMessageChannel());
await service.RegisterGroupAsync("G1");
await service.RegisterGroupAsync("G1"); // second call must not duplicate
Assert.Equal(1, await db.MessagingGroups.CountAsync());
Assert.True((await db.MessagingGroups.SingleAsync()).IsActive);
await service.DeactivateGroupAsync("G1");
Assert.False((await db.MessagingGroups.SingleAsync()).IsActive);
}
[Fact]
public async Task SendLineAsync_PushesToBoundMembersAndActiveGroups_AndLogs()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
db.MemberChannelBindings.Add(new MemberChannelBinding
{
MemberId = memberId, Channel = "line", ExternalId = "U-MEM", BoundAt = DateTime.UtcNow,
});
var activeGroup = new MessagingGroup { Channel = "line", ExternalId = "G-ON", IsActive = true, RegisteredAt = DateTime.UtcNow };
var deadGroup = new MessagingGroup { Channel = "line", ExternalId = "G-OFF", IsActive = false, RegisteredAt = DateTime.UtcNow };
db.MessagingGroups.AddRange(activeGroup, deadGroup);
await db.SaveChangesAsync();
var channel = new FakeMessageChannel();
var service = new LineNotificationService(db, channel);
var result = await service.SendLineAsync("notice", new[] { memberId },
new[] { activeGroup.Id, deadGroup.Id }, "admin-1");
Assert.Equal(2, result.SentCount); // member + active group only
Assert.Single(channel.UserPushes);
Assert.Single(channel.GroupPushes); // inactive group skipped
Assert.Equal(2, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Sent));
}
[Fact]
public async Task SendLineAsync_RecordsFailures_WhenChannelFails()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
db.MemberChannelBindings.Add(new MemberChannelBinding
{
MemberId = memberId, Channel = "line", ExternalId = "U-MEM", BoundAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
var service = new LineNotificationService(db, new FakeMessageChannel { Fail = true });
var result = await service.SendLineAsync("notice", new[] { memberId }, Array.Empty<int>(), "admin-1");
Assert.Equal(0, result.SentCount);
Assert.Equal(1, result.FailedCount);
Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed));
}
[Fact]
public async Task SendLineAsync_SkipsSoftDeletedMembers()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
db.MemberChannelBindings.Add(new MemberChannelBinding
{
MemberId = memberId, Channel = "line", ExternalId = "U-DEL", BoundAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
// Soft-delete the member.
var member = await db.Members.FirstAsync(m => m.Id == memberId);
member.IsDeleted = true;
await db.SaveChangesAsync();
var channel = new FakeMessageChannel();
var service = new LineNotificationService(db, channel);
var result = await service.SendLineAsync("notice", new[] { memberId }, Array.Empty<int>(), "admin-1");
Assert.Equal(0, result.SentCount);
Assert.Empty(channel.UserPushes);
}
}
@@ -0,0 +1,47 @@
using System.Security.Cryptography;
using System.Text;
using ROLAC.API.Services.Notifications;
using Xunit;
namespace ROLAC.API.Tests.Services.Notifications;
public class LineSignatureTests
{
private const string Secret = "test-channel-secret";
private static string Sign(string body)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(Secret));
return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(body)));
}
[Fact]
public void IsValid_ReturnsTrue_ForMatchingSignature()
{
var body = """{"events":[]}""";
var signature = Sign(body);
var result = LineSignature.IsValid(Secret, Encoding.UTF8.GetBytes(body), signature);
Assert.True(result);
}
[Fact]
public void IsValid_ReturnsFalse_ForTamperedBody()
{
var signature = Sign("""{"events":[]}""");
var result = LineSignature.IsValid(Secret, Encoding.UTF8.GetBytes("""{"events":[1]}"""), signature);
Assert.False(result);
}
[Fact]
public void IsValid_ReturnsFalse_ForNullOrEmptyHeader()
{
var body = Encoding.UTF8.GetBytes("""{"events":[]}""");
Assert.False(LineSignature.IsValid(Secret, body, null));
Assert.False(LineSignature.IsValid(Secret, body, ""));
}
}
@@ -36,7 +36,7 @@ public class OfferingSessionServiceTests
private static AppDbContext BuildDb(string userId = "test-user")
{
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId));
var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId)));
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
@@ -0,0 +1,185 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using ROLAC.API.Authorization;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Permissions;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class PermissionServiceTests
{
// -----------------------------------------------------------------------
// Harness: a real PermissionService backed by an in-memory EF database.
// -----------------------------------------------------------------------
private sealed class Harness
{
public required ServiceProvider Provider { get; init; }
public required PermissionService Service { get; init; }
public async Task SeedRoleAsync(string roleName, params RolePermission[] permissions)
{
using var scope = Provider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var role = new AppRole { Id = $"role-{roleName}", Name = roleName, NormalizedName = roleName.ToUpperInvariant() };
db.Roles.Add(role);
foreach (var permission in permissions)
{
permission.RoleId = role.Id;
db.RolePermissions.Add(permission);
}
await db.SaveChangesAsync();
}
}
private static Harness BuildHarness()
{
var dbName = Guid.NewGuid().ToString();
var services = new ServiceCollection();
services.AddDbContext<AppDbContext>(opt => opt.UseInMemoryDatabase(dbName));
var provider = services.BuildServiceProvider();
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
var cache = new MemoryCache(new MemoryCacheOptions());
return new Harness
{
Provider = provider,
Service = new PermissionService(scopeFactory, cache,
new ROLAC.API.Services.Logging.SystemLogQueue(),
new Microsoft.AspNetCore.Http.HttpContextAccessor()),
};
}
private static RolePermission Perm(string module, bool r = false, bool w = false, bool d = false, bool a = false)
=> new() { Module = module, CanRead = r, CanWrite = w, CanDelete = d, CanApprove = a };
// -----------------------------------------------------------------------
// HasPermissionAsync
// -----------------------------------------------------------------------
[Fact]
public async Task HasPermission_RoleGrantsAction_ReturnsTrue()
{
var h = BuildHarness();
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true, w: true));
Assert.True(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Read));
Assert.True(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Write));
}
[Fact]
public async Task HasPermission_RoleLacksAction_ReturnsFalse()
{
var h = BuildHarness();
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true)); // read only
Assert.False(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Delete));
Assert.False(await h.Service.HasPermissionAsync(["finance"], Modules.Members, PermissionActions.Read));
}
[Fact]
public async Task HasPermission_UnionAcrossRoles_ReturnsTrueIfAnyRoleGrants()
{
var h = BuildHarness();
await h.SeedRoleAsync("pastor", Perm(Modules.Members, r: true));
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true, w: true));
// User holds both roles — should get the union.
Assert.True(await h.Service.HasPermissionAsync(["pastor", "finance"], Modules.Members, PermissionActions.Read));
Assert.True(await h.Service.HasPermissionAsync(["pastor", "finance"], Modules.Givings, PermissionActions.Write));
Assert.False(await h.Service.HasPermissionAsync(["pastor", "finance"], Modules.Members, PermissionActions.Delete));
}
// -----------------------------------------------------------------------
// GetEffectivePermissionsAsync
// -----------------------------------------------------------------------
[Fact]
public async Task GetEffectivePermissions_SuperAdmin_ReturnsAllModulesFull()
{
var h = BuildHarness(); // no rows seeded at all
var effective = await h.Service.GetEffectivePermissionsAsync(["super_admin"]);
Assert.Equal(Modules.All.Count, effective.Count);
foreach (var module in Modules.All)
{
Assert.True(effective[module].Read);
Assert.True(effective[module].Write);
Assert.True(effective[module].Delete);
Assert.True(effective[module].Approve);
}
}
[Fact]
public async Task GetEffectivePermissions_MergesFlagsAcrossRoles()
{
var h = BuildHarness();
await h.SeedRoleAsync("a", Perm(Modules.Expenses, r: true));
await h.SeedRoleAsync("b", Perm(Modules.Expenses, w: true, a: true));
var effective = await h.Service.GetEffectivePermissionsAsync(["a", "b"]);
Assert.True(effective[Modules.Expenses].Read);
Assert.True(effective[Modules.Expenses].Write);
Assert.True(effective[Modules.Expenses].Approve);
Assert.False(effective[Modules.Expenses].Delete);
}
[Fact]
public async Task GetEffectivePermissions_OmitsModulesWithNoGrant()
{
var h = BuildHarness();
await h.SeedRoleAsync("member"); // role exists but no grants
var effective = await h.Service.GetEffectivePermissionsAsync(["member"]);
Assert.Empty(effective);
}
// -----------------------------------------------------------------------
// Caching / invalidation via UpsertRoleAsync
// -----------------------------------------------------------------------
[Fact]
public async Task UpsertRole_InvalidatesCache_SoNextCheckReflectsNewState()
{
var h = BuildHarness();
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true)); // read only
// Prime the cache with the original snapshot.
Assert.False(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Write));
// Grant write; UpsertRoleAsync must invalidate the cache.
await h.Service.UpsertRoleAsync("finance", [new ModulePermissionDto
{
Module = Modules.Givings, CanRead = true, CanWrite = true,
}]);
Assert.True(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Write));
}
[Fact]
public async Task UpsertRole_SuperAdmin_Throws()
{
var h = BuildHarness();
await h.SeedRoleAsync("super_admin");
await Assert.ThrowsAsync<InvalidOperationException>(
() => h.Service.UpsertRoleAsync("super_admin", [new ModulePermissionDto { Module = Modules.Members, CanRead = true }]));
}
[Fact]
public async Task UpsertRole_UnknownRole_Throws()
{
var h = BuildHarness();
await Assert.ThrowsAsync<KeyNotFoundException>(
() => h.Service.UpsertRoleAsync("ghost", [new ModulePermissionDto { Module = Modules.Members, CanRead = true }]));
}
}
@@ -76,7 +76,7 @@ public class UserManagementServiceTests
mgr.Setup(m => m.Users)
.Returns(new List<AppUser>().AsQueryable());
var svc = new UserManagementService(mgr.Object, db);
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
var result = await svc.CreateAsync(new CreateUserRequest
{
MemberId = member.Id,
@@ -97,7 +97,7 @@ public class UserManagementServiceTests
var mgr = BuildUserManager();
mgr.Setup(m => m.Users)
.Returns(new List<AppUser>().AsQueryable());
var svc = new UserManagementService(mgr.Object, db);
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.CreateAsync(new CreateUserRequest
@@ -131,7 +131,7 @@ public class UserManagementServiceTests
// The service checks _userManager.Users — we need to return the existing user
mgr.Setup(m => m.Users)
.Returns(new List<AppUser> { existingUser }.AsQueryable());
var svc = new UserManagementService(mgr.Object, db);
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.CreateAsync(new CreateUserRequest
@@ -147,7 +147,7 @@ public class UserManagementServiceTests
var user = new AppUser
{ Id = "u1", UserName = "a@b.com", Email = "a@b.com", IsActive = true };
var mgr = BuildUserManager(findResult: user);
var svc = new UserManagementService(mgr.Object, db);
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
await svc.DeactivateAsync("u1");
@@ -160,7 +160,7 @@ public class UserManagementServiceTests
{
using var db = BuildDb();
var mgr = BuildUserManager(findResult: null);
var svc = new UserManagementService(mgr.Object, db);
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeactivateAsync("missing"));
}
@@ -173,7 +173,7 @@ public class UserManagementServiceTests
using var db = BuildDb();
var user = new AppUser { Id = "u1", UserName = "a@b.com", Email = "a@b.com" };
var mgr = BuildUserManager(findResult: user);
var svc = new UserManagementService(mgr.Object, db);
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
var pwd = await svc.ResetPasswordAsync("u1");
@@ -0,0 +1,19 @@
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Tests.TestSupport;
/// <summary>No-op <see cref="IAuditLogger"/> for unit tests that don't assert on audit output.</summary>
public sealed class NullAuditLogger : IAuditLogger
{
public static readonly NullAuditLogger Instance = new();
public void Write(
string action, string category, LogLevelEnum level = LogLevelEnum.Information,
string? entityName = null, string? entityId = null, string? summary = null,
object? before = null, object? after = null,
string? userId = null, string? userEmail = null, string? ipAddress = null)
{
// intentionally empty
}
}
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Authorization;
namespace ROLAC.API.Authorization;
/// <summary>
/// Gates an action/controller on a configurable permission. Usage:
/// <c>[HasPermission(Modules.Members, PermissionActions.Write)]</c>.
/// Encodes the policy name <c>PERM:&lt;module&gt;:&lt;action&gt;</c>, which
/// <see cref="PermissionPolicyProvider"/> turns into a <see cref="PermissionRequirement"/>.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public class HasPermissionAttribute : AuthorizeAttribute
{
public const string PolicyPrefix = "PERM:";
public HasPermissionAttribute(string module, string action)
=> Policy = $"{PolicyPrefix}{module}:{action}";
/// <summary>Parses a policy name back into (module, action), or null if not a PERM policy.</summary>
public static (string Module, string Action)? Parse(string policyName)
{
if (!policyName.StartsWith(PolicyPrefix, StringComparison.Ordinal))
return null;
var body = policyName[PolicyPrefix.Length..];
var split = body.IndexOf(':');
if (split <= 0 || split == body.Length - 1)
return null;
return (body[..split], body[(split + 1)..]);
}
}
+66
View File
@@ -0,0 +1,66 @@
namespace ROLAC.API.Authorization;
/// <summary>
/// Canonical list of permission-controlled modules. The names are stored verbatim
/// in <see cref="Entities.RolePermission.Module"/> and used in <c>[HasPermission]</c>
/// attributes, so changing a string here is a breaking change requiring a data update.
/// </summary>
public static class Modules
{
public const string Members = "Members";
public const string Users = "Users";
public const string Givings = "Givings";
public const string GivingCategories = "GivingCategories";
public const string Expenses = "Expenses";
public const string ExpenseCategories = "ExpenseCategories";
public const string OfferingSessions = "OfferingSessions";
public const string Ministries = "Ministries";
public const string FinanceDashboard = "FinanceDashboard";
public const string MonthlyStatements = "MonthlyStatements";
public const string ChurchProfile = "ChurchProfile";
public const string Disbursements = "Disbursements";
public const string MealAttendance = "MealAttendance";
public const string Permissions = "Permissions";
public const string SystemLogs = "SystemLogs";
public const string AuditLogs = "AuditLogs";
/// <summary>All modules, in display order — drives the admin matrix UI.</summary>
public static readonly IReadOnlyList<string> All =
[
Members,
Users,
Givings,
GivingCategories,
Expenses,
ExpenseCategories,
OfferingSessions,
Ministries,
FinanceDashboard,
MonthlyStatements,
ChurchProfile,
Disbursements,
MealAttendance,
Permissions,
SystemLogs,
AuditLogs,
];
public static bool IsValid(string module) => All.Contains(module);
}
/// <summary>
/// The four actions a role can be granted on a module. The default HTTP-verb mapping
/// is GET→Read, POST/PUT/PATCH→Write, DELETE→Delete; "Approve" is applied explicitly
/// to state-transition endpoints (approve / finalize / issue / sign, etc.).
/// </summary>
public static class PermissionActions
{
public const string Read = "Read";
public const string Write = "Write";
public const string Delete = "Delete";
public const string Approve = "Approve";
public static readonly IReadOnlyList<string> All = [Read, Write, Delete, Approve];
public static bool IsValid(string action) => All.Contains(action);
}
@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Authorization;
using ROLAC.API.Services;
namespace ROLAC.API.Authorization;
/// <summary>
/// Evaluates <see cref="PermissionRequirement"/> against the user's roles.
/// <c>super_admin</c> always passes (bypass); otherwise the requirement succeeds if
/// ANY of the user's roles grants the requested module/action (union across roles).
/// </summary>
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
{
public const string SuperAdminRole = "super_admin";
private readonly IPermissionService _permissions;
public PermissionAuthorizationHandler(IPermissionService permissions)
=> _permissions = permissions;
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context, PermissionRequirement requirement)
{
// Roles live in "role" claims (RoleClaimType = "role", MapInboundClaims = false).
var roles = context.User.FindAll("role").Select(claim => claim.Value).ToList();
if (roles.Contains(SuperAdminRole))
{
context.Succeed(requirement);
return;
}
if (await _permissions.HasPermissionAsync(roles, requirement.Module, requirement.Action))
context.Succeed(requirement);
}
}
@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
namespace ROLAC.API.Authorization;
/// <summary>
/// Materializes <c>PERM:&lt;module&gt;:&lt;action&gt;</c> policies on demand so we never
/// have to register every module/action combination at startup. Any other policy name
/// (including the default and <c>Roles=</c> policies) is delegated to the framework's
/// default provider, so existing <c>[Authorize(Roles=...)]</c> usages keep working.
/// </summary>
public class PermissionPolicyProvider : IAuthorizationPolicyProvider
{
private readonly DefaultAuthorizationPolicyProvider _fallback;
public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
=> _fallback = new DefaultAuthorizationPolicyProvider(options);
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
var parsed = HasPermissionAttribute.Parse(policyName);
if (parsed is null)
return _fallback.GetPolicyAsync(policyName);
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddRequirements(new PermissionRequirement(parsed.Value.Module, parsed.Value.Action))
.Build();
return Task.FromResult<AuthorizationPolicy?>(policy);
}
}
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Authorization;
namespace ROLAC.API.Authorization;
/// <summary>
/// Authorization requirement carrying the module + action a request needs.
/// Materialized on demand by <see cref="PermissionPolicyProvider"/> from a policy
/// name of the form <c>PERM:&lt;module&gt;:&lt;action&gt;</c>.
/// </summary>
public class PermissionRequirement : IAuthorizationRequirement
{
public string Module { get; }
public string Action { get; }
public PermissionRequirement(string module, string action)
{
Module = module;
Action = action;
}
}
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/audit-logs")]
[Authorize]
public class AuditLogsController : ControllerBase
{
private readonly IAuditLogQueryService _svc;
public AuditLogsController(IAuditLogQueryService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.AuditLogs, PermissionActions.Read)]
public async Task<IActionResult> GetPaged([FromQuery] AuditLogQuery query)
=> Ok(await _svc.GetPagedAsync(query));
[HttpGet("{id:long}")]
[HasPermission(Modules.AuditLogs, PermissionActions.Read)]
public async Task<IActionResult> GetById(long id)
{
var dto = await _svc.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Category / action / level option lists for the filter UI.</summary>
[HttpGet("catalog")]
[HasPermission(Modules.AuditLogs, PermissionActions.Read)]
public IActionResult GetCatalog() => Ok(_svc.GetCatalog());
}
+71 -7
View File
@@ -1,6 +1,9 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Auth;
using ROLAC.API.Entities;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
@@ -13,11 +16,14 @@ public class AuthController : ControllerBase
private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
private readonly IAuthService _authService;
private readonly UserManager<AppUser> _userManager;
private readonly IWebHostEnvironment _env;
public AuthController(IAuthService authService, IWebHostEnvironment env)
public AuthController(
IAuthService authService, UserManager<AppUser> userManager, IWebHostEnvironment env)
{
_authService = authService;
_userManager = userManager;
_env = env;
}
@@ -79,17 +85,43 @@ public class AuthController : ControllerBase
}
// -------------------------------------------------------------------------
// GET /api/auth/me (dev-only diagnostic — remove before production)
// GET /api/auth/me
// -------------------------------------------------------------------------
/// <summary>
/// Returns the claims ASP.NET Core parsed from the Bearer token.
/// Use this to debug 401 vs 403: if you get 200 here, the JWT validates
/// fine; if you then get 403 on /api/users the role claim isn't matching.
/// Returns the current user's identity, roles, and effective permissions.
/// The SPA calls this on startup and after an admin edits the permission matrix
/// to refresh what the UI shows — without forcing a re-login.
/// </summary>
[HttpGet("me")]
[Authorize] // no role restriction — just needs a valid JWT
public IActionResult GetMe()
[Authorize]
[ProducesResponseType(typeof(UserInfo), StatusCodes.Status200OK)]
public async Task<IActionResult> GetMe()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
if (string.IsNullOrEmpty(userId))
return Unauthorized();
var user = await _userManager.FindByIdAsync(userId);
if (user is null || !user.IsActive)
return Unauthorized();
var roles = await _userManager.GetRolesAsync(user);
return Ok(await _authService.BuildUserInfoAsync(user, roles));
}
// -------------------------------------------------------------------------
// GET /api/auth/claims (dev-only diagnostic)
// -------------------------------------------------------------------------
/// <summary>
/// Returns the raw claims ASP.NET Core parsed from the Bearer token.
/// Use this to debug 401 vs 403: if you get 200 here, the JWT validates fine;
/// if you then get 403 on a protected endpoint the role/permission isn't matching.
/// </summary>
[HttpGet("claims")]
[Authorize]
public IActionResult GetClaims()
{
var claims = User.Claims
.Select(c => new { c.Type, c.Value })
@@ -122,6 +154,38 @@ public class AuthController : ControllerBase
return NoContent();
}
// -------------------------------------------------------------------------
// POST /api/auth/change-password
// -------------------------------------------------------------------------
/// <summary>
/// Changes the current user's password. Requires the correct current password and a
/// new password meeting the configured policy. On success the user's *other* sessions
/// are revoked while the current session stays active.
/// </summary>
[HttpPost("change-password")]
[Authorize]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
if (string.IsNullOrEmpty(userId))
return Unauthorized();
var currentRefresh = Request.Cookies[CookieName];
var result = await _authService.ChangePasswordAsync(
userId, request.CurrentPassword, request.NewPassword, currentRefresh);
if (!result.Succeeded)
return BadRequest(new
{
message = string.Join(" ", result.Errors.Select(error => error.Description)),
});
return NoContent();
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.Services;
@@ -7,16 +8,18 @@ namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/church-profile")]
[Authorize(Roles = "finance,super_admin")]
[Authorize]
public class ChurchProfileController : ControllerBase
{
private readonly IChurchProfileService _svc;
public ChurchProfileController(IChurchProfileService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.ChurchProfile, PermissionActions.Read)]
public async Task<IActionResult> Get() => Ok(await _svc.GetAsync());
[HttpPut]
[HasPermission(Modules.ChurchProfile, PermissionActions.Write)]
public async Task<IActionResult> Update([FromBody] UpdateChurchProfileRequest r)
{
await _svc.UpdateAsync(r);
@@ -1,6 +1,7 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.Services;
@@ -8,17 +9,19 @@ namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/disbursements")]
[Authorize(Roles = "finance,super_admin")]
[Authorize]
public class DisbursementsController : ControllerBase
{
private readonly IDisbursementService _svc;
public DisbursementsController(IDisbursementService svc) => _svc = svc;
[HttpGet("approved-unpaid")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetApprovedUnpaid()
=> Ok(await _svc.GetApprovedUnpaidGroupedAsync());
[HttpPost("issue")]
[HasPermission(Modules.Disbursements, PermissionActions.Write)]
public async Task<IActionResult> Issue([FromBody] IssueChecksRequest r)
{
try { return Ok(await _svc.IssueChecksAsync(r)); }
@@ -27,12 +30,14 @@ public class DisbursementsController : ControllerBase
}
[HttpGet("checks")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetRegister(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? status = null,
[FromQuery] string? search = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
=> Ok(await _svc.GetRegisterAsync(page, pageSize, status, search, from, to));
[HttpGet("checks/{id:int}")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id)
{
var dto = await _svc.GetByIdAsync(id);
@@ -40,6 +45,7 @@ public class DisbursementsController : ControllerBase
}
[HttpPost("checks/{id:int}/void")]
[HasPermission(Modules.Disbursements, PermissionActions.Delete)]
public async Task<IActionResult> Void(int id, [FromBody] VoidCheckRequest r)
{
try { await _svc.VoidAsync(id, r.Reason); return NoContent(); }
@@ -48,6 +54,7 @@ public class DisbursementsController : ControllerBase
}
[HttpGet("checks/{id:int}/pdf")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetPdf(int id)
{
var result = await _svc.RenderPdfAsync(id);
@@ -56,6 +63,7 @@ public class DisbursementsController : ControllerBase
}
[HttpGet("checks/{id:int}/receipt-pdf")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetReceiptPdf(int id)
{
var result = await _svc.RenderReceiptPdfAsync(id);
@@ -64,6 +72,7 @@ public class DisbursementsController : ControllerBase
}
[HttpPost("checks/{id:int}/acknowledge")]
[HasPermission(Modules.Disbursements, PermissionActions.Approve)]
[RequestSizeLimit(5_242_880)]
public async Task<IActionResult> Acknowledge(int id, [FromForm] IFormFile signature, [FromForm] string signedName)
{
@@ -82,6 +91,7 @@ public class DisbursementsController : ControllerBase
}
[HttpGet("checks/{id:int}/signature")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetSignature(int id)
{
try
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services;
@@ -19,32 +20,32 @@ public class ExpenseCategoriesController : ControllerBase
=> Ok(await _svc.GetAllAsync(includeInactive));
[HttpPost("groups")]
[Authorize(Roles = "finance,super_admin")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)
=> Ok(new { id = await _svc.CreateGroupAsync(r) });
[HttpPut("groups/{id:int}")]
[Authorize(Roles = "finance,super_admin")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateExpenseGroupRequest r)
{ try { await _svc.UpdateGroupAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
[HttpDelete("groups/{id:int}")]
[Authorize(Roles = "finance,super_admin")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Delete)]
public async Task<IActionResult> DeactivateGroup(int id)
{ try { await _svc.DeactivateGroupAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
[HttpPost("subcategories")]
[Authorize(Roles = "finance,super_admin")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> CreateSub([FromBody] CreateExpenseSubCategoryRequest r)
{ try { return Ok(new { id = await _svc.CreateSubCategoryAsync(r) }); } catch (KeyNotFoundException) { return NotFound(); } }
[HttpPut("subcategories/{id:int}")]
[Authorize(Roles = "finance,super_admin")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> UpdateSub(int id, [FromBody] UpdateExpenseSubCategoryRequest r)
{ try { await _svc.UpdateSubCategoryAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
[HttpDelete("subcategories/{id:int}")]
[Authorize(Roles = "finance,super_admin")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Delete)]
public async Task<IActionResult> DeactivateSub(int id)
{ try { await _svc.DeactivateSubCategoryAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
}
+31 -14
View File
@@ -1,21 +1,38 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
// Class is [Authorize] only — any authenticated member may submit/view their OWN
// reimbursements. Finance-level privileges (view-all, edit-any, approve) are resolved
// against the configurable permission matrix on the "Expenses" module.
[ApiController]
[Route("api/expenses")]
[Authorize]
public class ExpensesController : ControllerBase
{
private readonly IExpenseService _svc;
public ExpensesController(IExpenseService svc) => _svc = svc;
private readonly IExpenseService _svc;
private readonly IPermissionService _perms;
public ExpensesController(IExpenseService svc, IPermissionService perms)
{
_svc = svc;
_perms = perms;
}
private bool IsFinance() => User.IsInRole("finance") || User.IsInRole("super_admin");
private bool CanViewAll() => IsFinance() || User.IsInRole("pastor");
private List<string> Roles() => User.FindAll("role").Select(claim => claim.Value).ToList();
private bool IsSuperAdmin() => User.IsInRole(PermissionAuthorizationHandler.SuperAdminRole);
// Can manage any expense (edit/delete/upload on others' records). Maps to Expenses:Write.
private async Task<bool> CanManageAsync() =>
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Write);
// Can view all expenses (not just own). Maps to Expenses:Read (finance + pastor by default).
private async Task<bool> CanViewAllAsync() =>
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Read);
// User id lives in the "sub" claim (NameClaimType="sub"); NameIdentifier is absent at runtime.
private string CurrentUserId() =>
@@ -28,7 +45,7 @@ public class ExpensesController : ControllerBase
[FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null,
[FromQuery] int? subCategoryId = null, [FromQuery] string? statuses = null)
{
if (!CanViewAll()) return Forbid();
if (!await CanViewAllAsync()) return Forbid();
return Ok(await _svc.GetPagedAsync(page, pageSize, search, ministryId, categoryGroupId, status, from, to, subCategoryId, statuses));
}
@@ -43,21 +60,21 @@ public class ExpensesController : ControllerBase
{
var dto = await _svc.GetByIdAsync(id);
if (dto is null) return NotFound();
if (!CanViewAll() && dto.SubmittedBy != CurrentUserId()) return Forbid();
if (!await CanViewAllAsync() && dto.SubmittedBy != CurrentUserId()) return Forbid();
return Ok(dto);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateExpenseRequest r)
{
try { return Ok(new { id = await _svc.CreateAsync(r, IsFinance()) }); }
try { return Ok(new { id = await _svc.CreateAsync(r, await CanManageAsync()) }); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateExpenseRequest r)
{
try { await _svc.UpdateAsync(id, r, IsFinance()); return NoContent(); }
try { await _svc.UpdateAsync(id, r, await CanManageAsync()); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
@@ -65,7 +82,7 @@ public class ExpensesController : ControllerBase
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
try { await _svc.DeleteAsync(id, IsFinance()); return NoContent(); }
try { await _svc.DeleteAsync(id, await CanManageAsync()); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
@@ -79,7 +96,7 @@ public class ExpensesController : ControllerBase
}
[HttpPost("{id:int}/approve")]
[Authorize(Roles = "finance,super_admin")]
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
public async Task<IActionResult> Approve(int id)
{
try { await _svc.ApproveAsync(id); return NoContent(); }
@@ -88,7 +105,7 @@ public class ExpensesController : ControllerBase
}
[HttpPost("{id:int}/reject")]
[Authorize(Roles = "finance,super_admin")]
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
public async Task<IActionResult> Reject(int id, [FromBody] RejectExpenseRequest r)
{
try { await _svc.RejectAsync(id, r.ReviewNotes); return NoContent(); }
@@ -97,7 +114,7 @@ public class ExpensesController : ControllerBase
}
[HttpPost("{id:int}/pay")]
[Authorize(Roles = "finance,super_admin")]
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
public async Task<IActionResult> Pay(int id, [FromBody] PayExpenseRequest r)
{
try { await _svc.PayAsync(id, r.CheckNumber, r.PaidAt); return NoContent(); }
@@ -115,7 +132,7 @@ public class ExpensesController : ControllerBase
try
{
await using var stream = file.OpenReadStream();
await _svc.SaveReceiptAsync(id, stream, file.FileName, IsFinance());
await _svc.SaveReceiptAsync(id, stream, file.FileName, await CanManageAsync());
return NoContent();
}
catch (KeyNotFoundException) { return NotFound(); }
@@ -127,7 +144,7 @@ public class ExpensesController : ControllerBase
{
try
{
var result = await _svc.OpenReceiptAsync(id, IsFinance());
var result = await _svc.OpenReceiptAsync(id, await CanManageAsync());
if (result is null) return NotFound();
return File(result.Value.stream, result.Value.contentType);
}
@@ -1,12 +1,13 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/finance-dashboard")]
[Authorize(Roles = "finance,super_admin")]
[HasPermission(Modules.FinanceDashboard, PermissionActions.Read)]
public class FinanceDashboardController : ControllerBase
{
private readonly IFinanceDashboardService _svc;
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services;
@@ -7,17 +8,19 @@ namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/giving-categories")]
[Authorize(Roles = "finance,super_admin")]
[Authorize]
public class GivingCategoriesController : ControllerBase
{
private readonly IGivingCategoryService _svc;
public GivingCategoriesController(IGivingCategoryService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.GivingCategories, PermissionActions.Read)]
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
=> Ok(await _svc.GetAllAsync(includeInactive));
[HttpPost]
[HasPermission(Modules.GivingCategories, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateGivingCategoryRequest request)
{
var id = await _svc.CreateAsync(request);
@@ -25,6 +28,7 @@ public class GivingCategoriesController : ControllerBase
}
[HttpPut("{id:int}")]
[HasPermission(Modules.GivingCategories, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingCategoryRequest request)
{
try { await _svc.UpdateAsync(id, request); return NoContent(); }
@@ -32,6 +36,7 @@ public class GivingCategoriesController : ControllerBase
}
[HttpDelete("{id:int}")]
[HasPermission(Modules.GivingCategories, PermissionActions.Delete)]
public async Task<IActionResult> Deactivate(int id)
{
try { await _svc.DeactivateAsync(id); return NoContent(); }
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services;
@@ -7,13 +8,14 @@ namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/givings")]
[Authorize(Roles = "finance,super_admin")]
[Authorize]
public class GivingsController : ControllerBase
{
private readonly IGivingService _svc;
public GivingsController(IGivingService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.Givings, PermissionActions.Read)]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] string? search = null, [FromQuery] int? categoryId = null,
@@ -21,6 +23,7 @@ public class GivingsController : ControllerBase
=> Ok(await _svc.GetPagedAsync(page, pageSize, search, categoryId, from, to));
[HttpGet("{id:int}")]
[HasPermission(Modules.Givings, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id)
{
var dto = await _svc.GetByIdAsync(id);
@@ -28,6 +31,7 @@ public class GivingsController : ControllerBase
}
[HttpPost]
[HasPermission(Modules.Givings, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateGivingRequest request)
{
var id = await _svc.CreateAsync(request);
@@ -35,6 +39,7 @@ public class GivingsController : ControllerBase
}
[HttpPut("{id:int}")]
[HasPermission(Modules.Givings, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingRequest request)
{
try { await _svc.UpdateAsync(id, request); return NoContent(); }
@@ -43,6 +48,7 @@ public class GivingsController : ControllerBase
}
[HttpDelete("{id:int}")]
[HasPermission(Modules.Givings, PermissionActions.Delete)]
public async Task<IActionResult> Delete(int id)
{
try { await _svc.DeleteAsync(id); return NoContent(); }
@@ -0,0 +1,89 @@
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using ROLAC.API.DTOs.Notifications;
using ROLAC.API.Services.Notifications;
namespace ROLAC.API.Controllers;
/// <summary>
/// Anonymous Line webhook. Verifies the X-Line-Signature over the raw body, then dispatches
/// follow/message/join/leave events. Always returns 200 for valid payloads so Line does not retry;
/// returns 400 only on signature failure.
/// </summary>
[ApiController]
[Route("api/line")]
[AllowAnonymous]
public sealed class LineWebhookController : ControllerBase
{
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
private readonly ILineNotificationService _line;
private readonly IMessageChannel _channel;
private readonly LineOptions _options;
public LineWebhookController(
ILineNotificationService line, IMessageChannel channel, IOptions<LineOptions> options)
{
_line = line;
_channel = channel;
_options = options.Value;
}
[HttpPost("webhook")]
[RequestSizeLimit(262_144)]
public async Task<IActionResult> Webhook(CancellationToken ct)
{
using var reader = new StreamReader(Request.Body, Encoding.UTF8);
var rawBody = await reader.ReadToEndAsync(ct);
var signature = Request.Headers["X-Line-Signature"].FirstOrDefault();
if (!LineSignature.IsValid(_options.ChannelSecret, Encoding.UTF8.GetBytes(rawBody), signature))
return BadRequest();
var payload = JsonSerializer.Deserialize<LineWebhookPayload>(rawBody, JsonOpts);
if (payload?.Events is not null)
foreach (var evt in payload.Events)
await DispatchAsync(evt, ct);
return Ok();
}
private async Task DispatchAsync(LineWebhookEvent evt, CancellationToken ct)
{
switch (evt.Type)
{
case "follow":
if (evt.ReplyToken is not null)
await _channel.ReplyAsync(evt.ReplyToken, "歡迎!請輸入您的綁定碼以連結教會帳號。", ct);
break;
case "message":
if (evt.Message?.Type == "text"
&& evt.Source?.UserId is { } userId
&& evt.Message.Text is { } text)
{
var result = await _line.TryBindMemberAsync(userId, text, ct);
if (evt.ReplyToken is not null)
await _channel.ReplyAsync(evt.ReplyToken, result.Message, ct);
}
break;
case "join":
if (evt.Source?.GroupId is { } joinGroupId)
{
await _line.RegisterGroupAsync(joinGroupId, ct);
if (evt.ReplyToken is not null)
await _channel.ReplyAsync(evt.ReplyToken, "已加入群組,請至後台命名此群組。", ct);
}
break;
case "leave":
if (evt.Source?.GroupId is { } leaveGroupId)
await _line.DeactivateGroupAsync(leaveGroupId, ct);
break;
}
}
}
@@ -16,7 +16,7 @@ public class MealAttendanceController : ControllerBase
[HttpGet("today")]
[AllowAnonymous]
public async Task<IActionResult> GetToday()
=> Ok(await _svc.GetOrCreateAsync(_svc.Today));
=> Ok(await _svc.GetOrCreateAsync(_svc.ServiceDay));
/// <summary>Daily counts within a date range, for the back-office dashboard chart.</summary>
[HttpGet]
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Members;
using ROLAC.API.Services;
@@ -15,7 +16,7 @@ public class MembersController : ControllerBase
/// <summary>GET /api/members?page=1&pageSize=20&search=Chen&status=Member&hasUser=false</summary>
[HttpGet]
[Authorize(Roles = "super_admin,secretary,pastor")]
[HasPermission(Modules.Members, PermissionActions.Read)]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
@@ -26,7 +27,7 @@ public class MembersController : ControllerBase
/// <summary>GET /api/members/{id}</summary>
[HttpGet("{id:int}")]
[Authorize(Roles = "super_admin,secretary,pastor")]
[HasPermission(Modules.Members, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id)
{
var dto = await _members.GetByIdAsync(id);
@@ -35,7 +36,7 @@ public class MembersController : ControllerBase
/// <summary>POST /api/members</summary>
[HttpPost]
[Authorize(Roles = "super_admin,secretary")]
[HasPermission(Modules.Members, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateMemberRequest request)
{
var id = await _members.CreateAsync(request);
@@ -44,7 +45,7 @@ public class MembersController : ControllerBase
/// <summary>PUT /api/members/{id}</summary>
[HttpPut("{id:int}")]
[Authorize(Roles = "super_admin,secretary")]
[HasPermission(Modules.Members, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateMemberRequest request)
{
try { await _members.UpdateAsync(id, request); return NoContent(); }
@@ -53,7 +54,7 @@ public class MembersController : ControllerBase
/// <summary>DELETE /api/members/{id} — soft delete</summary>
[HttpDelete("{id:int}")]
[Authorize(Roles = "super_admin,secretary")]
[HasPermission(Modules.Members, PermissionActions.Delete)]
public async Task<IActionResult> Delete(int id)
{
try { await _members.DeleteAsync(id); return NoContent(); }
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services;
@@ -7,17 +8,19 @@ namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/monthly-statements")]
[Authorize(Roles = "finance,super_admin")]
[Authorize]
public class MonthlyStatementsController : ControllerBase
{
private readonly IMonthlyStatementService _svc;
public MonthlyStatementsController(IMonthlyStatementService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Read)]
public async Task<IActionResult> GetAll([FromQuery] int? year = null)
=> Ok(await _svc.GetAllAsync(year));
[HttpGet("{id:int}")]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id)
{
var dto = await _svc.GetByIdAsync(id);
@@ -25,6 +28,7 @@ public class MonthlyStatementsController : ControllerBase
}
[HttpPost]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateMonthlyStatementRequest r)
{
try { return Ok(new { id = await _svc.CreateAsync(r) }); }
@@ -32,6 +36,7 @@ public class MonthlyStatementsController : ControllerBase
}
[HttpPut("{id:int}")]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateMonthlyStatementRequest r)
{
try { await _svc.UpdateAsync(id, r); return NoContent(); }
@@ -40,6 +45,7 @@ public class MonthlyStatementsController : ControllerBase
}
[HttpPost("{id:int}/finalize")]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Approve)]
public async Task<IActionResult> Finalize(int id)
{
try { await _svc.FinalizeAsync(id); return NoContent(); }
@@ -0,0 +1,95 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Notifications;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Notifications;
namespace ROLAC.API.Controllers;
/// <summary>
/// Admin endpoints for the notification module (API-only phase). Binding-code generation, group
/// management, send history, and manual send — the manual send endpoints are the only way to fire
/// a message before a UI exists; programmatic callers use the services directly.
/// </summary>
[ApiController]
[Route("api/notifications")]
[Authorize]
public sealed class NotificationsController : ControllerBase
{
private readonly IEmailService _email;
private readonly ILineNotificationService _line;
private readonly AppDbContext _db;
private readonly CurrentUserAccessor _currentUser;
public NotificationsController(
IEmailService email, ILineNotificationService line,
AppDbContext db, CurrentUserAccessor currentUser)
{
_email = email;
_line = line;
_db = db;
_currentUser = currentUser;
}
[HttpPost("members/{id:int}/line-binding-code")]
public async Task<IActionResult> GenerateBindingCode(int id, CancellationToken ct)
=> Ok(new { code = await _line.GenerateLineBindingCodeAsync(id, ct) });
[HttpGet("groups")]
public async Task<IActionResult> Groups(CancellationToken ct)
=> Ok(await _db.MessagingGroups
.OrderBy(g => g.Id)
.Select(g => new { g.Id, g.Name, g.IsActive, g.RegisteredAt })
.ToListAsync(ct));
[HttpPut("groups/{id:int}")]
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateGroupRequest request, CancellationToken ct)
{
var group = await _db.MessagingGroups.FirstOrDefaultAsync(g => g.Id == id, ct);
if (group is null) return NotFound();
group.Name = request.Name;
group.IsActive = request.IsActive;
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpGet("history")]
public async Task<IActionResult> History(
[FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken ct = default)
{
var size = Math.Clamp(pageSize, 1, 200);
var skip = (Math.Max(page, 1) - 1) * size;
var query = _db.NotificationLogs.OrderByDescending(l => l.SentAt);
var total = await query.CountAsync(ct);
var items = await query
.Skip(skip).Take(size)
.Select(l => new
{
l.Id, l.Channel, l.TargetType, l.TargetExternalId, l.Subject,
l.Status, l.Error, l.SentByUserId, l.SentAt,
})
.ToListAsync(ct);
return Ok(new { total, items });
}
[HttpPost("send-line")]
public async Task<IActionResult> SendLine([FromBody] SendLineRequest request, CancellationToken ct)
=> Ok(await _line.SendLineAsync(
request.Body, request.MemberIds ?? [], request.GroupIds ?? [],
_currentUser.UserIdOrSystem, ct));
[HttpPost("send-email")]
public async Task<IActionResult> SendEmail([FromBody] SendEmailRequest request, CancellationToken ct)
=> Ok(await _email.SendAsync(new EmailMessage(
MemberIds: request.MemberIds ?? [],
Addresses: request.Addresses ?? [],
Subject: request.Subject,
HtmlBody: request.HtmlBody,
Attachments: null,
SentByUserId: _currentUser.UserIdOrSystem), ct));
}
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services;
@@ -7,23 +8,26 @@ namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/offering-sessions")]
[Authorize(Roles = "finance,super_admin")]
[Authorize]
public class OfferingSessionsController : ControllerBase
{
private readonly IOfferingSessionService _svc;
public OfferingSessionsController(IOfferingSessionService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
=> Ok(await _svc.GetPagedAsync(page, pageSize, from, to));
[HttpGet("check-date")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
public async Task<IActionResult> CheckDate([FromQuery] DateOnly date)
=> Ok(new { exists = await _svc.DateExistsAsync(date) });
[HttpGet("{id:int}")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id)
{
var dto = await _svc.GetByIdAsync(id);
@@ -31,6 +35,7 @@ public class OfferingSessionsController : ControllerBase
}
[HttpPost]
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateOfferingSessionRequest request)
{
try
@@ -42,6 +47,7 @@ public class OfferingSessionsController : ControllerBase
}
[HttpPost("{id:int}/reopen")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Approve)]
public async Task<IActionResult> Reopen(int id)
{
try { await _svc.ReopenAsync(id); return NoContent(); }
@@ -50,6 +56,7 @@ public class OfferingSessionsController : ControllerBase
}
[HttpPut("{id:int}")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
public async Task<IActionResult> Replace(int id, [FromBody] CreateOfferingSessionRequest request)
{
try { await _svc.ReplaceAsync(id, request); return NoContent(); }
@@ -60,6 +67,7 @@ public class OfferingSessionsController : ControllerBase
// ── Paper-proof PDF (merged client-side, one file per session) ───────────
[HttpPost("{id:int}/proof")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
[RequestSizeLimit(52_428_800)] // 50 MB — a merged multi-image PDF is larger than one receipt
public async Task<IActionResult> UploadProof(int id, IFormFile file)
{
@@ -75,6 +83,7 @@ public class OfferingSessionsController : ControllerBase
}
[HttpGet("{id:int}/proof")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
public async Task<IActionResult> GetProof(int id)
{
try
@@ -87,6 +96,7 @@ public class OfferingSessionsController : ControllerBase
}
[HttpDelete("{id:int}/proof")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Delete)]
public async Task<IActionResult> DeleteProof(int id)
{
try { await _svc.DeleteProofAsync(id); return NoContent(); }
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Permissions;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
/// <summary>
/// Admin surface for the configurable RBAC matrix. Restricted to super_admin —
/// the role that governs who governs everyone else.
/// </summary>
[ApiController]
[Route("api/permissions")]
[Authorize(Roles = "super_admin")]
public class PermissionsController : ControllerBase
{
private readonly IPermissionService _permissions;
public PermissionsController(IPermissionService permissions) => _permissions = permissions;
/// <summary>GET /api/permissions — the full role × module matrix.</summary>
[HttpGet]
public async Task<IActionResult> GetMatrix() => Ok(await _permissions.GetMatrixAsync());
/// <summary>GET /api/permissions/catalog — module + action names for the grid.</summary>
[HttpGet("catalog")]
public IActionResult GetCatalog() => Ok(new PermissionCatalogDto
{
Modules = Modules.All,
Actions = PermissionActions.All,
});
/// <summary>PUT /api/permissions/{roleName} — replaces a role's grants.</summary>
[HttpPut("{roleName}")]
public async Task<IActionResult> UpdateRole(string roleName, [FromBody] UpdateRolePermissionsRequest request)
{
try
{
await _permissions.UpsertRoleAsync(roleName, request.Modules);
return NoContent();
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
}
}
@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Logging;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/system-logs")]
[Authorize]
public class SystemLogsController : ControllerBase
{
private readonly ISystemLogQueryService _svc;
public SystemLogsController(ISystemLogQueryService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.SystemLogs, PermissionActions.Read)]
public async Task<IActionResult> GetPaged([FromQuery] SystemLogQuery query)
=> Ok(await _svc.GetPagedAsync(query));
[HttpGet("{id:long}")]
[HasPermission(Modules.SystemLogs, PermissionActions.Read)]
public async Task<IActionResult> GetById(long id)
{
var dto = await _svc.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>All six severities, so the UI can offer every filter option regardless of data.</summary>
[HttpGet("levels")]
[HasPermission(Modules.SystemLogs, PermissionActions.Read)]
public IActionResult GetLevels() => Ok(Enum.GetNames<LogLevelEnum>());
}
+8 -1
View File
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Users;
using ROLAC.API.Services;
@@ -7,7 +8,7 @@ namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/users")]
[Authorize(Roles = "super_admin")]
[Authorize]
public class UsersController : ControllerBase
{
private readonly IUserManagementService _users;
@@ -15,6 +16,7 @@ public class UsersController : ControllerBase
/// <summary>GET /api/users?page=1&pageSize=20&search=Chris</summary>
[HttpGet]
[HasPermission(Modules.Users, PermissionActions.Read)]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
@@ -23,6 +25,7 @@ public class UsersController : ControllerBase
/// <summary>GET /api/users/{id}</summary>
[HttpGet("{id}")]
[HasPermission(Modules.Users, PermissionActions.Read)]
public async Task<IActionResult> GetById(string id)
{
var dto = await _users.GetByIdAsync(id);
@@ -34,6 +37,7 @@ public class UsersController : ControllerBase
/// TempPassword is returned ONCE — show it to the admin and never log it.
/// </summary>
[HttpPost]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateUserRequest request)
{
try
@@ -49,6 +53,7 @@ public class UsersController : ControllerBase
/// <summary>PUT /api/users/{id} — update email, roles, IsActive</summary>
[HttpPut("{id}")]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> Update(string id, [FromBody] UpdateUserRequest request)
{
try { await _users.UpdateAsync(id, request); return NoContent(); }
@@ -58,6 +63,7 @@ public class UsersController : ControllerBase
/// <summary>DELETE /api/users/{id} — deactivates account (IsActive=false), does not delete</summary>
[HttpDelete("{id}")]
[HasPermission(Modules.Users, PermissionActions.Delete)]
public async Task<IActionResult> Deactivate(string id)
{
try { await _users.DeactivateAsync(id); return NoContent(); }
@@ -66,6 +72,7 @@ public class UsersController : ControllerBase
/// <summary>POST /api/users/{id}/reset-password — returns new temp password</summary>
[HttpPost("{id}/reset-password")]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> ResetPassword(string id)
{
try
@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Auth;
public class ChangePasswordRequest
{
[Required]
[MaxLength(128)]
public string CurrentPassword { get; set; } = null!;
[Required]
[MinLength(8)]
[MaxLength(128)]
public string NewPassword { get; set; } = null!;
}
+8
View File
@@ -1,3 +1,5 @@
using ROLAC.API.DTOs.Permissions;
namespace ROLAC.API.DTOs.Auth;
public class LoginResponse
@@ -17,4 +19,10 @@ public class UserInfo
public string Email { get; set; } = null!;
public IList<string> Roles { get; set; } = [];
public string LanguagePreference { get; set; } = "en";
/// <summary>
/// Effective permissions (union across the user's roles), keyed by module name.
/// Lets the SPA hide nav/buttons. Authoritative enforcement is server-side.
/// </summary>
public Dictionary<string, ModuleActions> Permissions { get; set; } = [];
}
@@ -0,0 +1,50 @@
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.DTOs.Logging;
/// <summary>Row shape for the Audit Logs grid (no heavy Changes JSON).</summary>
public class AuditLogListItemDto
{
public long Id { get; set; }
public DateTimeOffset Timestamp { get; set; }
public string Level { get; set; } = null!;
public string Action { get; set; } = null!;
public string Category { get; set; } = null!;
public string? EntityName { get; set; }
public string? EntityId { get; set; }
public string? Summary { get; set; }
public string? UserId { get; set; }
public string? UserEmail { get; set; }
}
/// <summary>Full detail for the Audit Log dialog, including the before→after JSON.</summary>
public class AuditLogDetailDto : AuditLogListItemDto
{
public string? Changes { get; set; }
public string? IpAddress { get; set; }
public string? CorrelationId { get; set; }
}
/// <summary>Filters for the paged Audit Logs query.</summary>
public class AuditLogQuery
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
public DateTimeOffset? From { get; set; }
public DateTimeOffset? To { get; set; }
public string? Category { get; set; }
public string? Action { get; set; }
public string? EntityName { get; set; }
public string? EntityId { get; set; }
public string? UserId { get; set; }
public LogLevelEnum? MinLevel { get; set; }
public string? Search { get; set; }
}
/// <summary>Option lists for the Audit Logs filter UI.</summary>
public class AuditCatalogDto
{
public IReadOnlyList<string> Categories { get; set; } = [];
public IReadOnlyList<string> Actions { get; set; } = [];
public IReadOnlyList<string> Levels { get; set; } = [];
}
@@ -0,0 +1,43 @@
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.DTOs.Logging;
/// <summary>Row shape for the System Logs grid (no heavy exception text).</summary>
public class SystemLogListItemDto
{
public long Id { get; set; }
public DateTimeOffset Timestamp { get; set; }
public string Level { get; set; } = null!;
public string Category { get; set; } = null!;
public string Message { get; set; } = null!;
public bool HasException { get; set; }
public int? StatusCode { get; set; }
public string? RequestPath { get; set; }
public string? HttpMethod { get; set; }
public string? UserId { get; set; }
public string? CorrelationId { get; set; }
}
/// <summary>Full detail for the System Log dialog, including the stack trace.</summary>
public class SystemLogDetailDto : SystemLogListItemDto
{
public int? EventId { get; set; }
public string? Exception { get; set; }
public string? IpAddress { get; set; }
}
/// <summary>Filters for the paged System Logs query.</summary>
public class SystemLogQuery
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
public DateTimeOffset? From { get; set; }
public DateTimeOffset? To { get; set; }
/// <summary>Lower bound on severity (inclusive).</summary>
public LogLevelEnum? MinLevel { get; set; }
/// <summary>Exact severity match (takes precedence over MinLevel when set).</summary>
public LogLevelEnum? Level { get; set; }
public string? Search { get; set; }
public string? UserId { get; set; }
public string? CorrelationId { get; set; }
}
@@ -0,0 +1,28 @@
namespace ROLAC.API.DTOs.Notifications;
/// <summary>Top-level Line webhook payload (deserialized case-insensitively).</summary>
public sealed class LineWebhookPayload
{
public List<LineWebhookEvent>? Events { get; set; }
}
public sealed class LineWebhookEvent
{
public string? Type { get; set; } // follow | message | join | leave | ...
public string? ReplyToken { get; set; }
public LineWebhookSource? Source { get; set; }
public LineWebhookMessage? Message { get; set; }
}
public sealed class LineWebhookSource
{
public string? Type { get; set; } // user | group | room
public string? UserId { get; set; }
public string? GroupId { get; set; }
}
public sealed class LineWebhookMessage
{
public string? Type { get; set; } // text | image | ...
public string? Text { get; set; }
}
@@ -0,0 +1,7 @@
namespace ROLAC.API.DTOs.Notifications;
public sealed record UpdateGroupRequest(string? Name, bool IsActive);
public sealed record SendLineRequest(string Body, int[]? MemberIds, int[]? GroupIds);
public sealed record SendEmailRequest(string Subject, string HtmlBody, int[]? MemberIds, string[]? Addresses);
@@ -0,0 +1,53 @@
namespace ROLAC.API.DTOs.Permissions;
/// <summary>Effective action flags for one module (union across a user's roles).</summary>
public class ModuleActions
{
public bool Read { get; set; }
public bool Write { get; set; }
public bool Delete { get; set; }
public bool Approve { get; set; }
public bool Any => Read || Write || Delete || Approve;
}
/// <summary>One module's grant for a single role — used in the admin matrix and updates.</summary>
public class ModulePermissionDto
{
public string Module { get; set; } = null!;
public bool CanRead { get; set; }
public bool CanWrite { get; set; }
public bool CanDelete { get; set; }
public bool CanApprove { get; set; }
}
/// <summary>One role's full row in the admin matrix (every module, dense).</summary>
public class RolePermissionRow
{
public string RoleName { get; set; } = null!;
public string? Description { get; set; }
/// <summary>super_admin is shown read-only/full — it bypasses the matrix.</summary>
public bool IsSuperAdmin { get; set; }
public List<ModulePermissionDto> Modules { get; set; } = [];
}
/// <summary>GET /api/permissions — the whole matrix plus the catalog for grid headers.</summary>
public class PermissionMatrixDto
{
public IReadOnlyList<string> AllModules { get; set; } = [];
public IReadOnlyList<string> AllActions { get; set; } = [];
public List<RolePermissionRow> Roles { get; set; } = [];
}
/// <summary>GET /api/permissions/catalog — module + action names for building the UI.</summary>
public class PermissionCatalogDto
{
public IReadOnlyList<string> Modules { get; set; } = [];
public IReadOnlyList<string> Actions { get; set; } = [];
}
/// <summary>PUT /api/permissions/{roleName} — replaces a role's grants.</summary>
public class UpdateRolePermissionsRequest
{
public List<ModulePermissionDto> Modules { get; set; } = [];
}
+70
View File
@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data.Logging;
using ROLAC.API.Entities;
using ROLAC.API.Entities.Notifications;
namespace ROLAC.API.Data;
@@ -23,6 +25,12 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
public DbSet<Check> Checks => Set<Check>();
public DbSet<CheckLine> CheckLines => Set<CheckLine>();
public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>();
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
public DbSet<MemberChannelBinding> MemberChannelBindings => Set<MemberChannelBinding>();
public DbSet<LineBindingCode> LineBindingCodes => Set<LineBindingCode>();
public DbSet<MessagingGroup> MessagingGroups => Set<MessagingGroup>();
public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();
protected override void OnModelCreating(ModelBuilder builder)
{
@@ -60,6 +68,18 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.Description).HasMaxLength(500);
});
// ── RolePermission (configurable RBAC matrix) ───────────────────────
builder.Entity<RolePermission>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.RoleId).HasMaxLength(450).IsRequired();
entity.Property(e => e.Module).HasMaxLength(60).IsRequired();
// One row per (role, module).
entity.HasIndex(e => new { e.RoleId, e.Module }).IsUnique();
entity.HasOne(e => e.Role).WithMany()
.HasForeignKey(e => e.RoleId).OnDelete(DeleteBehavior.Cascade);
});
// ── FamilyUnit ──────────────────────────────────────────────────────
builder.Entity<FamilyUnit>(entity =>
{
@@ -311,5 +331,55 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => new { e.Year, e.Month }).IsUnique();
});
// ── Notifications (email + Line) ─────────────────────────────────────
builder.Entity<MemberChannelBinding>(entity =>
{
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
entity.Property(e => e.ExternalId).HasMaxLength(100).IsRequired();
entity.HasIndex(e => new { e.MemberId, e.Channel }).IsUnique();
entity.HasIndex(e => new { e.Channel, e.ExternalId }).IsUnique();
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<LineBindingCode>(entity =>
{
entity.Property(e => e.Code).HasMaxLength(20).IsRequired();
entity.HasIndex(e => e.Code);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<MessagingGroup>(entity =>
{
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
entity.Property(e => e.ExternalId).HasMaxLength(100).IsRequired();
entity.Property(e => e.Name).HasMaxLength(200);
entity.HasIndex(e => new { e.Channel, e.ExternalId }).IsUnique();
});
builder.Entity<NotificationLog>(entity =>
{
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
entity.Property(e => e.TargetType).HasMaxLength(20).IsRequired();
entity.Property(e => e.TargetExternalId).HasMaxLength(200).IsRequired();
entity.Property(e => e.Subject).HasMaxLength(300);
entity.Property(e => e.Status).HasMaxLength(20).IsRequired();
entity.Property(e => e.SentByUserId).HasMaxLength(450).IsRequired();
entity.HasIndex(e => e.SentAt);
entity.HasIndex(e => e.Channel);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.MessagingGroup).WithMany()
.HasForeignKey(e => e.MessagingGroupId).OnDelete(DeleteBehavior.SetNull);
});
// ── SystemLog / AuditLog (append-only) ───────────────────────────────
// Mapped here for SCHEMA only — there are deliberately no DbSets on this
// context, so business code can't write logs through the audited context.
// Runtime reads/writes go through the dedicated LogDbContext. Including
// them in the model lets the single startup migration create the tables.
LogModelConfiguration.Configure(builder);
}
}
+63
View File
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Authorization;
using ROLAC.API.Entities;
namespace ROLAC.API.Data;
@@ -62,6 +63,67 @@ public static class DbSeeder
("visitor", "Visitor — public pages only"),
];
// Default permission matrix — mirrors the hard-coded [Authorize(Roles=...)] rules that
// existed before the configurable RBAC system, so day-one behavior is unchanged.
// super_admin is intentionally absent: it bypasses all checks (see PermissionAuthorizationHandler).
// R=Read, W=Write, D=Delete, A=Approve. Rows are inserted only if missing, so an admin's
// later edits via the Permissions UI are never clobbered on restart.
private static readonly (string Role, string Module, bool R, bool W, bool D, bool A)[] RolePermissionSeed =
[
// Secretary — manages member data.
("secretary", Modules.Members, true, true, true, false),
// Pastor — read-only overview of members and all expenses.
("pastor", Modules.Members, true, false, false, false),
("pastor", Modules.Expenses, true, false, false, false),
// Finance — full control over the finance modules.
("finance", Modules.Givings, true, true, true, false),
("finance", Modules.GivingCategories, true, true, true, false),
("finance", Modules.Expenses, true, true, true, true),
("finance", Modules.ExpenseCategories, true, true, true, false),
("finance", Modules.OfferingSessions, true, true, true, true),
("finance", Modules.FinanceDashboard, true, false, false, false),
("finance", Modules.MonthlyStatements, true, true, false, true),
("finance", Modules.ChurchProfile, true, true, false, false),
("finance", Modules.Disbursements, true, true, true, true),
// Logs — read-only. System logs are technical (pastor only); audit logs have
// governance value, so finance and board members can read them too.
("pastor", Modules.SystemLogs, true, false, false, false),
("pastor", Modules.AuditLogs, true, false, false, false),
("finance", Modules.AuditLogs, true, false, false, false),
("board_member", Modules.AuditLogs, true, false, false, false),
];
public static async Task SeedRolePermissionsAsync(AppDbContext db)
{
var rolesByName = await db.Roles
.Where(r => r.Name != null)
.ToDictionaryAsync(r => r.Name!, r => r.Id);
foreach (var (role, module, read, write, delete, approve) in RolePermissionSeed)
{
if (!rolesByName.TryGetValue(role, out var roleId))
continue;
var exists = await db.RolePermissions.AnyAsync(p => p.RoleId == roleId && p.Module == module);
if (exists)
continue; // never clobber an admin's edit
db.RolePermissions.Add(new RolePermission
{
RoleId = roleId,
Module = module,
CanRead = read,
CanWrite = write,
CanDelete = delete,
CanApprove = approve,
});
}
await db.SaveChangesAsync();
}
public static async Task SeedRolesAsync(RoleManager<AppRole> roleManager)
{
foreach (var (name, description) in Roles)
@@ -159,6 +221,7 @@ public static class DbSeeder
await SeedRolesAsync(roleManager);
var db = services.GetRequiredService<AppDbContext>();
await SeedRolePermissionsAsync(db);
await SeedGivingCategoriesAsync(db);
await SeedMinistriesAsync(db);
await SeedExpenseCategoriesAsync(db);
@@ -0,0 +1,177 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Diagnostics;
using ROLAC.API.Entities.Base;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Data.Interceptors;
/// <summary>
/// Writes a before→after <see cref="AuditLog"/> row for every Create/Update/Delete of an
/// <see cref="IAuditable"/> entity. Two-phase: snapshot changed values BEFORE save (while
/// original values are still available), then — AFTER save succeeds — read DB-generated keys and
/// enqueue the rows. Enqueuing (rather than inserting here) avoids a second SaveChanges, can't
/// fail the user's transaction, and never recurses through AppDbContext.
/// </summary>
public sealed class AuditLogInterceptor : SaveChangesInterceptor
{
private readonly SystemLogQueue _queue;
private readonly CurrentUserAccessor _currentUser;
private readonly List<PendingAudit> _pending = [];
public AuditLogInterceptor(SystemLogQueue queue, CurrentUserAccessor currentUser)
{
_queue = queue;
_currentUser = currentUser;
}
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData, InterceptionResult<int> result)
{
Capture(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
Capture(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
public override int SavedChanges(SaveChangesCompletedEventData eventData, int result)
{
Flush();
return base.SavedChanges(eventData, result);
}
public override ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default)
{
Flush();
return base.SavedChangesAsync(eventData, result, cancellationToken);
}
public override void SaveChangesFailed(DbContextErrorEventData eventData) => _pending.Clear();
public override Task SaveChangesFailedAsync(
DbContextErrorEventData eventData, CancellationToken cancellationToken = default)
{
_pending.Clear();
return Task.CompletedTask;
}
// ── Phase 1: snapshot before save ─────────────────────────────────────────
private void Capture(DbContext? db)
{
if (db is null)
return;
foreach (var entry in db.ChangeTracker.Entries())
{
if (entry.Entity is not IAuditable)
continue;
switch (entry.State)
{
case EntityState.Added:
_pending.Add(new PendingAudit(entry, AuditActions.Create, null, BuildValues(entry, current: true)));
break;
case EntityState.Deleted:
_pending.Add(new PendingAudit(entry, AuditActions.Delete, BuildValues(entry, current: false), null));
break;
case EntityState.Modified:
var before = new Dictionary<string, object?>();
var after = new Dictionary<string, object?>();
foreach (var property in entry.Properties)
{
if (!property.IsModified)
continue;
var name = property.Metadata.Name;
before[name] = Read(name, property.OriginalValue);
after[name] = Read(name, property.CurrentValue);
}
if (after.Count == 0)
break; // no real change (e.g. only audit timestamps touched on a no-op)
// A soft-delete (IsDeleted false→true) reads more naturally as a Delete.
var action = IsSoftDelete(after) ? AuditActions.Delete : AuditActions.Update;
_pending.Add(new PendingAudit(entry, action, before, after));
break;
}
}
}
// ── Phase 2: keys exist, enqueue ──────────────────────────────────────────
private void Flush()
{
if (_pending.Count == 0)
return;
var userId = _currentUser.UserId;
var userEmail = _currentUser.Email;
var ip = _currentUser.IpAddress;
var corr = _currentUser.CorrelationId;
foreach (var item in _pending)
{
_queue.TryEnqueue(new AuditLog
{
Timestamp = DateTimeOffset.UtcNow,
Level = LogLevelEnum.Information,
Action = item.Action,
Category = AuditCategories.DataChange,
EntityName = item.Entry.Metadata.ClrType.Name,
EntityId = ReadKey(item.Entry),
Changes = AuditChangeSerializer.BuildChanges(item.Before, item.After),
UserId = userId,
UserEmail = userEmail,
IpAddress = ip,
CorrelationId = corr,
});
}
_pending.Clear();
}
private static Dictionary<string, object?> BuildValues(EntityEntry entry, bool current)
{
var values = new Dictionary<string, object?>();
foreach (var property in entry.Properties)
{
if (property.Metadata.IsPrimaryKey())
continue;
var name = property.Metadata.Name;
values[name] = Read(name, current ? property.CurrentValue : property.OriginalValue);
}
return values;
}
private static object? Read(string propertyName, object? value) =>
AuditChangeSerializer.IsSensitive(propertyName) ? AuditChangeSerializer.MaskValue : value;
private static bool IsSoftDelete(Dictionary<string, object?> after) =>
after.TryGetValue("IsDeleted", out var value) && value is true;
private static string? ReadKey(EntityEntry entry)
{
var key = entry.Metadata.FindPrimaryKey();
if (key is null)
return null;
var parts = key.Properties
.Select(p => entry.Property(p.Name).CurrentValue?.ToString())
.Where(v => v is not null);
return string.Join(",", parts);
}
private sealed record PendingAudit(
EntityEntry Entry,
string Action,
Dictionary<string, object?>? Before,
Dictionary<string, object?>? After);
}
@@ -1,15 +1,15 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using ROLAC.API.Entities.Base;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Data.Interceptors;
public class AuditSaveChangesInterceptor : SaveChangesInterceptor
{
private readonly IHttpContextAccessor _http;
private readonly CurrentUserAccessor _currentUser;
public AuditSaveChangesInterceptor(IHttpContextAccessor http) => _http = http;
public AuditSaveChangesInterceptor(CurrentUserAccessor currentUser) => _currentUser = currentUser;
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData, InterceptionResult<int> result)
@@ -30,8 +30,7 @@ public class AuditSaveChangesInterceptor : SaveChangesInterceptor
{
if (db is null) return;
var userId = _http.HttpContext?.User
.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
var userId = _currentUser.UserIdOrSystem;
var now = DateTimeOffset.UtcNow;
foreach (var entry in db.ChangeTracker.Entries())
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Data.Logging;
/// <summary>
/// A minimal, write-mostly context dedicated to the SystemLog / AuditLog tables. It is the
/// structural break that prevents log-storms: it is registered WITHOUT the audit interceptors
/// and with a silent logger factory (see Program.cs), so persisting a log row produces no log
/// events that the DB sink would pick up. It shares the same physical database/connection as
/// AppDbContext, but the tables themselves are created by AppDbContext's migration — they are
/// only mapped here so this context can read/write them.
/// </summary>
public class LogDbContext : DbContext
{
public LogDbContext(DbContextOptions<LogDbContext> options) : base(options) { }
public DbSet<SystemLog> SystemLogs => Set<SystemLog>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
LogModelConfiguration.Configure(builder);
}
}
@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Data.Logging;
/// <summary>
/// Single source of truth for the SystemLog / AuditLog table schema. Applied by
/// <see cref="AppDbContext"/> (so the startup migration creates the tables) AND by
/// <see cref="LogDbContext"/> (so runtime reads/writes map to the same shape).
/// </summary>
public static class LogModelConfiguration
{
public static void Configure(ModelBuilder builder)
{
builder.Entity<SystemLog>(entity =>
{
entity.ToTable("SystemLogs");
entity.HasKey(e => e.Id);
entity.Property(e => e.Level).HasConversion<byte>();
entity.Property(e => e.Category).HasMaxLength(256).IsRequired();
entity.Property(e => e.Message).IsRequired(); // text
entity.Property(e => e.RequestPath).HasMaxLength(2048);
entity.Property(e => e.HttpMethod).HasMaxLength(10);
entity.Property(e => e.UserId).HasMaxLength(450);
entity.Property(e => e.IpAddress).HasMaxLength(45);
entity.Property(e => e.CorrelationId).HasMaxLength(64);
entity.HasIndex(e => e.Timestamp);
entity.HasIndex(e => e.Level);
entity.HasIndex(e => new { e.Timestamp, e.Level });
entity.HasIndex(e => e.UserId).HasFilter("\"UserId\" IS NOT NULL");
});
builder.Entity<AuditLog>(entity =>
{
entity.ToTable("AuditLogs");
entity.HasKey(e => e.Id);
entity.Property(e => e.Level).HasConversion<byte>();
entity.Property(e => e.Action).HasMaxLength(40).IsRequired();
entity.Property(e => e.Category).HasMaxLength(40).IsRequired();
entity.Property(e => e.EntityName).HasMaxLength(128);
entity.Property(e => e.EntityId).HasMaxLength(64);
entity.Property(e => e.Changes).HasColumnType("jsonb");
entity.Property(e => e.Summary).HasMaxLength(512);
entity.Property(e => e.UserId).HasMaxLength(450);
entity.Property(e => e.UserEmail).HasMaxLength(256);
entity.Property(e => e.IpAddress).HasMaxLength(45);
entity.Property(e => e.CorrelationId).HasMaxLength(64);
entity.HasIndex(e => e.Timestamp);
entity.HasIndex(e => new { e.Category, e.Timestamp });
entity.HasIndex(e => new { e.EntityName, e.EntityId });
entity.HasIndex(e => e.Action);
entity.HasIndex(e => e.UserId).HasFilter("\"UserId\" IS NOT NULL");
});
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace ROLAC.API.Entities.Base;
/// <summary>
/// Opt-in marker: entities implementing this are diffed by <c>AuditLogInterceptor</c>, which
/// writes a before→after AuditLog row on every Create/Update/Delete. Applied only to business
/// entities the church cares about — not to internal/high-churn rows (RefreshToken, log tables).
/// </summary>
public interface IAuditable
{
}
+1 -1
View File
@@ -6,7 +6,7 @@ namespace ROLAC.API.Entities;
/// expenses (its <see cref="Lines"/>). The payee name/address are snapshotted at
/// issue time so the printed check is reproducible even if member data later changes.
/// </summary>
public class Check : SoftDeleteEntity
public class Check : SoftDeleteEntity, IAuditable
{
public int Id { get; set; }
public string CheckNumber { get; set; } = null!;
+1 -1
View File
@@ -5,7 +5,7 @@ namespace ROLAC.API.Entities;
/// Singleton (Id == 1) holding the issuing church's identity, bank details, and the
/// running check-number counter used when disbursing checks. Seeded on startup.
/// </summary>
public class ChurchProfile : AuditableEntity
public class ChurchProfile : AuditableEntity, IAuditable
{
public int Id { get; set; }
public string Name { get; set; } = null!;
+1 -1
View File
@@ -1,7 +1,7 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
public class Expense : SoftDeleteEntity
public class Expense : SoftDeleteEntity, IAuditable
{
public int Id { get; set; }
public int MinistryId { get; set; }
@@ -1,7 +1,7 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
public class ExpenseCategoryGroup : AuditableEntity
public class ExpenseCategoryGroup : AuditableEntity, IAuditable
{
public int Id { get; set; }
public string Name_en { get; set; } = null!;
+1 -1
View File
@@ -1,7 +1,7 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
public class ExpenseSubCategory : AuditableEntity
public class ExpenseSubCategory : AuditableEntity, IAuditable
{
public int Id { get; set; }
public int GroupId { get; set; }
+1 -1
View File
@@ -2,7 +2,7 @@ using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
public class Giving : AuditableEntity
public class Giving : AuditableEntity, IAuditable
{
public int Id { get; set; }
public int? MemberId { get; set; }
+1 -1
View File
@@ -2,7 +2,7 @@ using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
public class GivingCategory : AuditableEntity
public class GivingCategory : AuditableEntity, IAuditable
{
public int Id { get; set; }
public string Name_en { get; set; } = null!;
@@ -0,0 +1,72 @@
namespace ROLAC.API.Entities.Logging;
/// <summary>
/// An append-only audit row recording a meaningful action: a data change (Create/Update/
/// Delete with before→after values), a security event (login, role/permission change), or a
/// key business action (check issued, expense approved, ...). Does NOT inherit AuditableEntity.
/// </summary>
public class AuditLog
{
public long Id { get; set; }
public DateTimeOffset Timestamp { get; set; }
public LogLevelEnum Level { get; set; } = LogLevelEnum.Information;
/// <summary>One of <see cref="AuditActions"/>.</summary>
public string Action { get; set; } = null!;
/// <summary>One of <see cref="AuditCategories"/> — drives the UI grouping.</summary>
public string Category { get; set; } = null!;
public string? EntityName { get; set; }
/// <summary>String to cover int, Guid and string primary keys uniformly.</summary>
public string? EntityId { get; set; }
/// <summary>JSON <c>{ "before": {...}, "after": {...} }</c> (jsonb column); sensitive fields masked.</summary>
public string? Changes { get; set; }
/// <summary>Human-readable one-liner, e.g. "Check #1042 issued to Acme — $1,200.00".</summary>
public string? Summary { get; set; }
public string? UserId { get; set; }
/// <summary>Denormalized actor email — survives user deletion and avoids a join in the grid.</summary>
public string? UserEmail { get; set; }
public string? IpAddress { get; set; }
public string? CorrelationId { get; set; }
}
/// <summary>Canonical audit action names (stored verbatim in <see cref="AuditLog.Action"/>).</summary>
public static class AuditActions
{
public const string Create = "Create";
public const string Update = "Update";
public const string Delete = "Delete";
public const string Login = "Login";
public const string Logout = "Logout";
public const string LoginFailed = "LoginFailed";
public const string RoleChanged = "RoleChanged";
public const string PasswordChanged = "PasswordChanged";
public const string UserDeactivated = "UserDeactivated";
public const string PermissionChanged = "PermissionChanged";
public const string CheckIssued = "CheckIssued";
public const string CheckVoided = "CheckVoided";
public const string ExpenseApproved = "ExpenseApproved";
public const string StatementFinalized = "StatementFinalized";
public static readonly IReadOnlyList<string> All =
[
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
PasswordChanged, UserDeactivated, PermissionChanged, CheckIssued,
CheckVoided, ExpenseApproved, StatementFinalized,
];
}
/// <summary>Top-level audit grouping (stored verbatim in <see cref="AuditLog.Category"/>).</summary>
public static class AuditCategories
{
public const string DataChange = "DataChange";
public const string Security = "Security";
public const string Business = "Business";
public static readonly IReadOnlyList<string> All = [DataChange, Security, Business];
}
@@ -0,0 +1,38 @@
using MsLogLevel = Microsoft.Extensions.Logging.LogLevel;
namespace ROLAC.API.Entities.Logging;
/// <summary>
/// Persisted severity for system and audit logs. Byte-backed so it stores compactly
/// as <c>smallint</c> and sorts/filters by ordinal. Deliberately omits the
/// <see cref="MsLogLevel.None"/> sentinel (value 6) — "None" means "log nothing" and
/// is meaningless once a row already exists.
/// </summary>
public enum LogLevelEnum : byte
{
Trace = 0,
Debug = 1,
Information = 2,
Warning = 3,
Error = 4,
Critical = 5,
}
public static class LogLevelMap
{
/// <summary>
/// Maps a framework <see cref="MsLogLevel"/> to our persisted enum.
/// <see cref="MsLogLevel.None"/> falls through to <see cref="LogLevelEnum.Critical"/>
/// (it never reaches the sink because the floor filter drops it first).
/// </summary>
public static LogLevelEnum FromMs(MsLogLevel level) => level switch
{
MsLogLevel.Trace => LogLevelEnum.Trace,
MsLogLevel.Debug => LogLevelEnum.Debug,
MsLogLevel.Information => LogLevelEnum.Information,
MsLogLevel.Warning => LogLevelEnum.Warning,
MsLogLevel.Error => LogLevelEnum.Error,
MsLogLevel.Critical => LogLevelEnum.Critical,
_ => LogLevelEnum.Critical,
};
}
@@ -0,0 +1,31 @@
namespace ROLAC.API.Entities.Logging;
/// <summary>
/// An append-only operational log row — one per persisted framework/app log event,
/// including every unhandled API exception captured by ExceptionHandlingMiddleware.
/// Intentionally does NOT inherit AuditableEntity: these rows are never updated and
/// must not be re-stamped or re-audited (that would recurse through the log pipeline).
/// </summary>
public class SystemLog
{
public long Id { get; set; }
public DateTimeOffset Timestamp { get; set; }
public LogLevelEnum Level { get; set; }
/// <summary>The ILogger category (source), e.g. "ROLAC.API.Controllers.GivingsController".</summary>
public string Category { get; set; } = null!;
public int? EventId { get; set; }
public string Message { get; set; } = null!;
/// <summary>Full <c>exception.ToString()</c> (type + message + stack), when present.</summary>
public string? Exception { get; set; }
public string? RequestPath { get; set; }
public string? HttpMethod { get; set; }
public int? StatusCode { get; set; }
/// <summary>The acting user id ("sub" claim), or null for background/system events.</summary>
public string? UserId { get; set; }
public string? IpAddress { get; set; }
public string? CorrelationId { get; set; }
}
+1 -1
View File
@@ -2,7 +2,7 @@ using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
public class Member : SoftDeleteEntity
public class Member : SoftDeleteEntity, IAuditable
{
public int Id { get; set; }
public string FirstName_en { get; set; } = null!;
+3 -1
View File
@@ -1,6 +1,8 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
public class Ministry
public class Ministry : IAuditable
{
public int Id { get; set; }
public string Name_en { get; set; } = null!;
+1 -1
View File
@@ -1,7 +1,7 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
public class MonthlyStatement : AuditableEntity
public class MonthlyStatement : AuditableEntity, IAuditable
{
public int Id { get; set; }
public int Year { get; set; }
@@ -0,0 +1,14 @@
using ROLAC.API.Entities;
namespace ROLAC.API.Entities.Notifications;
/// <summary>A short-lived code a member types to the Line bot to complete account binding.</summary>
public class LineBindingCode
{
public int Id { get; set; }
public string Code { get; set; } = null!;
public int MemberId { get; set; }
public Member? Member { get; set; }
public DateTime ExpiresAt { get; set; }
public DateTime? ConsumedAt { get; set; } // null = unused
}
@@ -0,0 +1,17 @@
using ROLAC.API.Entities;
namespace ROLAC.API.Entities.Notifications;
/// <summary>
/// Binds a member to an external channel account (e.g. a Line userId). Separate table so future
/// channels don't require changes to Member.
/// </summary>
public class MemberChannelBinding
{
public int Id { get; set; }
public int MemberId { get; set; }
public Member? Member { get; set; }
public string Channel { get; set; } = null!; // "line"
public string ExternalId { get; set; } = null!; // Line userId
public DateTime BoundAt { get; set; }
}
@@ -0,0 +1,12 @@
namespace ROLAC.API.Entities.Notifications;
/// <summary>A Line group the bot was added to. Named by an admin after the join event.</summary>
public class MessagingGroup
{
public int Id { get; set; }
public string Channel { get; set; } = null!; // "line"
public string ExternalId { get; set; } = null!; // Line groupId
public string? Name { get; set; }
public bool IsActive { get; set; } = true;
public DateTime RegisteredAt { get; set; }
}
@@ -0,0 +1,22 @@
using ROLAC.API.Entities;
namespace ROLAC.API.Entities.Notifications;
/// <summary>An append-only audit row for every email or Line send (success or failure).</summary>
public class NotificationLog
{
public long Id { get; set; }
public string Channel { get; set; } = null!; // "email" | "line"
public string TargetType { get; set; } = null!; // "email" | "user" | "group"
public string TargetExternalId { get; set; } = null!; // email address OR Line id
public string? Subject { get; set; } // email only
public int? MemberId { get; set; }
public Member? Member { get; set; }
public int? MessagingGroupId { get; set; }
public MessagingGroup? MessagingGroup { get; set; }
public string Body { get; set; } = null!;
public string Status { get; set; } = null!; // "sent" | "failed"
public string? Error { get; set; }
public string SentByUserId { get; set; } = null!;
public DateTime SentAt { get; set; }
}
+1 -1
View File
@@ -2,7 +2,7 @@ using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
public class OfferingSession : AuditableEntity
public class OfferingSession : AuditableEntity, IAuditable
{
public int Id { get; set; }
public DateOnly SessionDate { get; set; }
+25
View File
@@ -0,0 +1,25 @@
namespace ROLAC.API.Entities;
/// <summary>
/// One row per (Role × Module). Stores what the role may do on that module.
/// The effective permission for a user is the boolean OR of these flags across
/// all of the user's roles. <c>super_admin</c> is never stored here — it bypasses
/// permission checks entirely (see PermissionAuthorizationHandler).
/// </summary>
public class RolePermission
{
public int Id { get; set; }
/// <summary>FK to AspNetRoles.Id.</summary>
public string RoleId { get; set; } = null!;
/// <summary>Module constant name (see <see cref="Authorization.Modules"/>).</summary>
public string Module { get; set; } = null!;
public bool CanRead { get; set; }
public bool CanWrite { get; set; }
public bool CanDelete { get; set; }
public bool CanApprove { get; set; }
public AppRole Role { get; set; } = null!;
}
+3 -3
View File
@@ -18,7 +18,7 @@ public class AttendanceHub : Hub
// Push the current counts to a client the moment it connects.
public override async Task OnConnectedAsync()
{
var counts = await _svc.GetOrCreateAsync(_svc.Today);
var counts = await _svc.GetOrCreateAsync(_svc.ServiceDay);
await Clients.Caller.SendAsync("ReceiveCounts", counts);
await base.OnConnectedAsync();
}
@@ -26,14 +26,14 @@ public class AttendanceHub : Hub
// Apply a batched delta for one age group, then broadcast the new totals to everyone.
public async Task Increment(string category, int delta)
{
var counts = await _svc.IncrementAsync(_svc.Today, category, delta);
var counts = await _svc.IncrementAsync(_svc.ServiceDay, category, delta);
await Clients.All.SendAsync("ReceiveCounts", counts);
}
// Overwrite one age group with an absolute value, then broadcast the new totals to everyone.
public async Task SetCount(string category, int value)
{
var counts = await _svc.SetAsync(_svc.Today, category, value);
var counts = await _svc.SetAsync(_svc.ServiceDay, category, value);
await Clients.All.SendAsync("ReceiveCounts", counts);
}
}
@@ -0,0 +1,78 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
namespace ROLAC.API.Middleware;
/// <summary>
/// Catches any unhandled exception from the downstream pipeline, logs it (which flows through
/// the DB sink into SystemLogs at Error level with full stack + StatusCode 500), and returns a
/// clean RFC7807 problem+json response. Stack traces are never leaked to the client outside
/// Development. Registered as the FIRST middleware so it also catches auth/authorization faults.
/// </summary>
public sealed class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
private readonly IHostEnvironment _env;
public ExceptionHandlingMiddleware(
RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger, IHostEnvironment env)
{
_next = next;
_logger = logger;
_env = env;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
{
// The client went away — not a server error; don't log as 500.
return;
}
catch (Exception ex)
{
await HandleAsync(context, ex);
}
}
private async Task HandleAsync(HttpContext context, Exception exception)
{
// Logged here → picked up by the DB sink (Error ≥ Warning floor) with full ex.ToString().
_logger.LogError(
exception,
"Unhandled exception for {Method} {Path} (traceId {TraceId})",
context.Request.Method, context.Request.Path, context.TraceIdentifier);
if (context.Response.HasStarted)
{
// Too late to write a clean body; the log row above is still captured.
return;
}
var problem = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An unexpected error occurred.",
Type = "https://tools.ietf.org/html/rfc7231#section-6.6.1",
};
problem.Extensions["traceId"] = context.TraceIdentifier;
if (_env.IsDevelopment())
problem.Detail = exception.ToString();
context.Response.Clear();
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsync(JsonSerializer.Serialize(problem, JsonSerializerOptions));
}
private static readonly JsonSerializerOptions JsonSerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,176 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ROLAC.API.Migrations
{
/// <inheritdoc />
public partial class AddNotifications : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "LineBindingCodes",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Code = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
MemberId = table.Column<int>(type: "integer", nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ConsumedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_LineBindingCodes", x => x.Id);
table.ForeignKey(
name: "FK_LineBindingCodes_Members_MemberId",
column: x => x.MemberId,
principalTable: "Members",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MemberChannelBindings",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
MemberId = table.Column<int>(type: "integer", nullable: false),
Channel = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
ExternalId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
BoundAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MemberChannelBindings", x => x.Id);
table.ForeignKey(
name: "FK_MemberChannelBindings_Members_MemberId",
column: x => x.MemberId,
principalTable: "Members",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MessagingGroups",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Channel = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
ExternalId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
RegisteredAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MessagingGroups", x => x.Id);
});
migrationBuilder.CreateTable(
name: "NotificationLogs",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Channel = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
TargetType = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
TargetExternalId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Subject = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
MemberId = table.Column<int>(type: "integer", nullable: true),
MessagingGroupId = table.Column<int>(type: "integer", nullable: true),
Body = table.Column<string>(type: "text", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
Error = table.Column<string>(type: "text", nullable: true),
SentByUserId = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
SentAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_NotificationLogs", x => x.Id);
table.ForeignKey(
name: "FK_NotificationLogs_Members_MemberId",
column: x => x.MemberId,
principalTable: "Members",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_NotificationLogs_MessagingGroups_MessagingGroupId",
column: x => x.MessagingGroupId,
principalTable: "MessagingGroups",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_LineBindingCodes_Code",
table: "LineBindingCodes",
column: "Code");
migrationBuilder.CreateIndex(
name: "IX_LineBindingCodes_MemberId",
table: "LineBindingCodes",
column: "MemberId");
migrationBuilder.CreateIndex(
name: "IX_MemberChannelBindings_Channel_ExternalId",
table: "MemberChannelBindings",
columns: new[] { "Channel", "ExternalId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_MemberChannelBindings_MemberId_Channel",
table: "MemberChannelBindings",
columns: new[] { "MemberId", "Channel" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_MessagingGroups_Channel_ExternalId",
table: "MessagingGroups",
columns: new[] { "Channel", "ExternalId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NotificationLogs_Channel",
table: "NotificationLogs",
column: "Channel");
migrationBuilder.CreateIndex(
name: "IX_NotificationLogs_MemberId",
table: "NotificationLogs",
column: "MemberId");
migrationBuilder.CreateIndex(
name: "IX_NotificationLogs_MessagingGroupId",
table: "NotificationLogs",
column: "MessagingGroupId");
migrationBuilder.CreateIndex(
name: "IX_NotificationLogs_SentAt",
table: "NotificationLogs",
column: "SentAt");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "LineBindingCodes");
migrationBuilder.DropTable(
name: "MemberChannelBindings");
migrationBuilder.DropTable(
name: "NotificationLogs");
migrationBuilder.DropTable(
name: "MessagingGroups");
}
}
}
@@ -885,6 +885,143 @@ namespace ROLAC.API.Migrations
b.ToTable("GivingCategories");
});
modelBuilder.Entity("ROLAC.API.Entities.Logging.AuditLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("Changes")
.HasColumnType("jsonb");
b.Property<string>("CorrelationId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("EntityId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("EntityName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("IpAddress")
.HasMaxLength(45)
.HasColumnType("character varying(45)");
b.Property<byte>("Level")
.HasColumnType("smallint");
b.Property<string>("Summary")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("UserId")
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("Action");
b.HasIndex("Timestamp");
b.HasIndex("UserId")
.HasFilter("\"UserId\" IS NOT NULL");
b.HasIndex("Category", "Timestamp");
b.HasIndex("EntityName", "EntityId");
b.ToTable("AuditLogs", (string)null);
});
modelBuilder.Entity("ROLAC.API.Entities.Logging.SystemLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("CorrelationId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int?>("EventId")
.HasColumnType("integer");
b.Property<string>("Exception")
.HasColumnType("text");
b.Property<string>("HttpMethod")
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("IpAddress")
.HasMaxLength(45)
.HasColumnType("character varying(45)");
b.Property<byte>("Level")
.HasColumnType("smallint");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("RequestPath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int?>("StatusCode")
.HasColumnType("integer");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserId")
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("Level");
b.HasIndex("Timestamp");
b.HasIndex("UserId")
.HasFilter("\"UserId\" IS NOT NULL");
b.HasIndex("Timestamp", "Level");
b.ToTable("SystemLogs", (string)null);
});
modelBuilder.Entity("ROLAC.API.Entities.MealAttendance", b =>
{
b.Property<int>("Id")
@@ -1186,6 +1323,174 @@ namespace ROLAC.API.Migrations
b.ToTable("MonthlyStatements");
});
modelBuilder.Entity("ROLAC.API.Entities.Notifications.LineBindingCode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime?>("ConsumedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("MemberId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Code");
b.HasIndex("MemberId");
b.ToTable("LineBindingCodes");
});
modelBuilder.Entity("ROLAC.API.Entities.Notifications.MemberChannelBinding", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("BoundAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Channel")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("MemberId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Channel", "ExternalId")
.IsUnique();
b.HasIndex("MemberId", "Channel")
.IsUnique();
b.ToTable("MemberChannelBindings");
});
modelBuilder.Entity("ROLAC.API.Entities.Notifications.MessagingGroup", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Channel")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("RegisteredAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Channel", "ExternalId")
.IsUnique();
b.ToTable("MessagingGroups");
});
modelBuilder.Entity("ROLAC.API.Entities.Notifications.NotificationLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Channel")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Error")
.HasColumnType("text");
b.Property<int?>("MemberId")
.HasColumnType("integer");
b.Property<int?>("MessagingGroupId")
.HasColumnType("integer");
b.Property<DateTime>("SentAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("SentByUserId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Subject")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("TargetExternalId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.HasKey("Id");
b.HasIndex("Channel");
b.HasIndex("MemberId");
b.HasIndex("MessagingGroupId");
b.HasIndex("SentAt");
b.ToTable("NotificationLogs");
});
modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b =>
{
b.Property<int>("Id")
@@ -1310,6 +1615,44 @@ namespace ROLAC.API.Migrations
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("ROLAC.API.Entities.RolePermission", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("CanApprove")
.HasColumnType("boolean");
b.Property<bool>("CanDelete")
.HasColumnType("boolean");
b.Property<bool>("CanRead")
.HasColumnType("boolean");
b.Property<bool>("CanWrite")
.HasColumnType("boolean");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("RoleId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("RoleId", "Module")
.IsUnique();
b.ToTable("RolePermissions");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("ROLAC.API.Entities.AppRole", null)
@@ -1470,6 +1813,45 @@ namespace ROLAC.API.Migrations
b.Navigation("FamilyUnit");
});
modelBuilder.Entity("ROLAC.API.Entities.Notifications.LineBindingCode", b =>
{
b.HasOne("ROLAC.API.Entities.Member", "Member")
.WithMany()
.HasForeignKey("MemberId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Member");
});
modelBuilder.Entity("ROLAC.API.Entities.Notifications.MemberChannelBinding", b =>
{
b.HasOne("ROLAC.API.Entities.Member", "Member")
.WithMany()
.HasForeignKey("MemberId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Member");
});
modelBuilder.Entity("ROLAC.API.Entities.Notifications.NotificationLog", b =>
{
b.HasOne("ROLAC.API.Entities.Member", "Member")
.WithMany()
.HasForeignKey("MemberId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ROLAC.API.Entities.Notifications.MessagingGroup", "MessagingGroup")
.WithMany()
.HasForeignKey("MessagingGroupId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Member");
b.Navigation("MessagingGroup");
});
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
{
b.HasOne("ROLAC.API.Entities.AppUser", "User")
@@ -1481,6 +1863,17 @@ namespace ROLAC.API.Migrations
b.Navigation("User");
});
modelBuilder.Entity("ROLAC.API.Entities.RolePermission", b =>
{
b.HasOne("ROLAC.API.Entities.AppRole", "Role")
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
});
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
{
b.Navigation("RefreshTokens");
+55 -1
View File
@@ -6,11 +6,15 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Logging.Abstractions;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Data.Logging;
using ROLAC.API.Entities;
using ROLAC.API.Json;
using ROLAC.API.Middleware;
using ROLAC.API.Services;
using ROLAC.API.Services.Logging;
var builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration;
@@ -19,10 +23,31 @@ var config = builder.Configuration;
// Database
// ---------------------------------------------------------------------------
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<CurrentUserAccessor>();
builder.Services.AddScoped<AuditSaveChangesInterceptor>();
builder.Services.AddScoped<AuditLogInterceptor>();
builder.Services.AddDbContext<AppDbContext>((sp, opt) =>
opt.UseNpgsql(config.GetConnectionString("DefaultConnection"))
.AddInterceptors(sp.GetRequiredService<AuditSaveChangesInterceptor>()));
.AddInterceptors(
sp.GetRequiredService<AuditSaveChangesInterceptor>(),
sp.GetRequiredService<AuditLogInterceptor>()));
// Dedicated context for log writes — NO interceptors and a silent logger factory, so persisting
// a log row produces no log events the DB sink would pick up (breaks recursion / log-storms).
builder.Services.AddDbContext<LogDbContext>(opt =>
opt.UseNpgsql(config.GetConnectionString("DefaultConnection"))
.UseLoggerFactory(NullLoggerFactory.Instance));
// ---------------------------------------------------------------------------
// System + audit logging (custom EF DB sink)
// ---------------------------------------------------------------------------
builder.Services.Configure<DatabaseLoggerOptions>(config.GetSection("Logging:Database"));
builder.Services.AddSingleton<SystemLogQueue>();
builder.Services.AddSingleton<ILoggerProvider, DbLoggerProvider>();
builder.Services.AddHostedService<LogWriterBackgroundService>();
builder.Services.AddScoped<IAuditLogger, AuditLogger>();
builder.Services.AddScoped<ISystemLogQueryService, SystemLogQueryService>();
builder.Services.AddScoped<IAuditLogQueryService, AuditLogQueryService>();
// ---------------------------------------------------------------------------
// Identity (API-only — no cookie auth; JWT is the default scheme)
@@ -135,6 +160,31 @@ builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
ROLAC.API.Services.Disbursement.CheckPrintService>();
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
// ── Notifications (email via SMTP + Line) ──────────────────────────────────
builder.Services.Configure<ROLAC.API.Services.Notifications.SmtpOptions>(config.GetSection("Smtp"));
builder.Services.Configure<ROLAC.API.Services.Notifications.LineOptions>(config.GetSection("Line"));
builder.Services.AddScoped<ROLAC.API.Services.Notifications.ISmtpDispatcher,
ROLAC.API.Services.Notifications.MailKitSmtpDispatcher>();
builder.Services.AddScoped<ROLAC.API.Services.Notifications.IEmailService,
ROLAC.API.Services.Notifications.EmailService>();
builder.Services.AddScoped<ROLAC.API.Services.Notifications.ILineNotificationService,
ROLAC.API.Services.Notifications.LineNotificationService>();
builder.Services.AddHttpClient<ROLAC.API.Services.Notifications.IMessageChannel,
ROLAC.API.Services.Notifications.LineMessageChannel>();
// ---------------------------------------------------------------------------
// Configurable role-based permissions (RBAC matrix)
// ---------------------------------------------------------------------------
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<IPermissionService, PermissionService>();
builder.Services.AddAuthorization();
// Dynamic policy provider materializes "PERM:<module>:<action>" policies on demand;
// must be registered AFTER AddAuthorization so it overrides the default provider.
builder.Services.AddSingleton<Microsoft.AspNetCore.Authorization.IAuthorizationPolicyProvider,
ROLAC.API.Authorization.PermissionPolicyProvider>();
builder.Services.AddScoped<Microsoft.AspNetCore.Authorization.IAuthorizationHandler,
ROLAC.API.Authorization.PermissionAuthorizationHandler>();
// Real-time hub for the live Sunday attendance counter.
builder.Services.AddSignalR();
@@ -187,6 +237,10 @@ app.UseForwardedHeaders(new ForwardedHeadersOptions
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
// First in the pipeline: catch every unhandled exception, log it to SystemLogs, and return
// a clean problem+json. Placed after UseForwardedHeaders so the logged client IP is correct.
app.UseMiddleware<ExceptionHandlingMiddleware>();
// Apply migrations + seed on startup
using (var scope = app.Services.CreateScope())
{
+1
View File
@@ -12,6 +12,7 @@
Provides DevExpress.Drawing.v24.1.Skia.dll; without it RichEditDocumentServer
throws DllNotFoundException at runtime on Linux (Windows falls back to GDI+). -->
<PackageReference Include="DevExpress.Drawing.Skia" Version="24.1.3" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
+94 -10
View File
@@ -4,6 +4,8 @@ using Microsoft.Extensions.Configuration;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Auth;
using ROLAC.API.Entities;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Services;
@@ -12,17 +14,23 @@ public class AuthService : IAuthService
private readonly UserManager<AppUser> _userManager;
private readonly ITokenService _tokenService;
private readonly AppDbContext _db;
private readonly IPermissionService _permissions;
private readonly IAuditLogger _audit;
private readonly int _refreshTokenExpiryDays;
public AuthService(
UserManager<AppUser> userManager,
ITokenService tokenService,
AppDbContext db,
IPermissionService permissions,
IAuditLogger audit,
IConfiguration config)
{
_userManager = userManager;
_tokenService = tokenService;
_db = db;
_permissions = permissions;
_audit = audit;
_refreshTokenExpiryDays = int.Parse(config["Jwt:RefreshTokenExpiryDays"] ?? "30");
}
@@ -35,13 +43,22 @@ public class AuthService : IAuthService
{
var user = await _userManager.FindByEmailAsync(request.Email);
if (user is null)
{
AuditLoginFailed(request.Email, "Unknown email", ipAddress);
throw new UnauthorizedAccessException("Invalid credentials.");
}
if (!await _userManager.CheckPasswordAsync(user, request.Password))
{
AuditLoginFailed(request.Email, "Wrong password", ipAddress, user.Id);
throw new UnauthorizedAccessException("Invalid credentials.");
}
if (!user.IsActive)
{
AuditLoginFailed(request.Email, "Account inactive", ipAddress, user.Id);
throw new UnauthorizedAccessException("Account is inactive.");
}
var roles = await _userManager.GetRolesAsync(user);
var accessToken = _tokenService.GenerateAccessToken(user, roles);
@@ -62,9 +79,22 @@ public class AuthService : IAuthService
await _userManager.UpdateAsync(user);
await _db.SaveChangesAsync();
return (BuildResponse(accessToken, user, roles), rawRefresh);
_audit.Write(
AuditActions.Login, AuditCategories.Security, LogLevelEnum.Information,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"Login succeeded: {user.Email}",
userId: user.Id, userEmail: user.Email, ipAddress: ipAddress);
return (await BuildResponseAsync(accessToken, user, roles), rawRefresh);
}
private void AuditLoginFailed(string email, string reason, string? ipAddress, string? userId = null)
=> _audit.Write(
AuditActions.LoginFailed, AuditCategories.Security, LogLevelEnum.Warning,
entityName: nameof(AppUser), entityId: userId,
summary: $"Login failed ({reason}): {email}",
userId: userId, userEmail: email, ipAddress: ipAddress);
// -------------------------------------------------------------------------
// Refresh
// -------------------------------------------------------------------------
@@ -104,7 +134,7 @@ public class AuthService : IAuthService
await _db.SaveChangesAsync();
return (BuildResponse(newAccess, user, roles), newRaw);
return (await BuildResponseAsync(newAccess, user, roles), newRaw);
}
// -------------------------------------------------------------------------
@@ -121,25 +151,79 @@ public class AuthService : IAuthService
{
token.RevokedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
_audit.Write(
AuditActions.Logout, AuditCategories.Security, LogLevelEnum.Information,
entityName: nameof(AppUser), entityId: token.UserId,
summary: "Logout", userId: token.UserId);
}
}
// -------------------------------------------------------------------------
// Change password
// -------------------------------------------------------------------------
public async Task<IdentityResult> ChangePasswordAsync(
string userId, string currentPassword, string newPassword, string? currentRawRefreshToken)
{
var user = await _userManager.FindByIdAsync(userId);
if (user is null)
return IdentityResult.Failed(new IdentityError
{
Code = "UserNotFound",
Description = "User not found.",
});
var result = await _userManager.ChangePasswordAsync(user, currentPassword, newPassword);
if (!result.Succeeded)
return result;
// Revoke the user's other active sessions; keep the current one alive.
var currentHash = currentRawRefreshToken is null
? null
: _tokenService.HashToken(currentRawRefreshToken);
var otherTokens = await _db.RefreshTokens
.Where(rt => rt.UserId == userId
&& rt.RevokedAt == null
&& (currentHash == null || rt.TokenHash != currentHash))
.ToListAsync();
foreach (var token in otherTokens)
token.RevokedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
_audit.Write(
AuditActions.PasswordChanged, AuditCategories.Security, LogLevelEnum.Information,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"Password changed: {user.Email}",
userId: user.Id, userEmail: user.Email);
return result;
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private static LoginResponse BuildResponse(
private async Task<LoginResponse> BuildResponseAsync(
string accessToken, AppUser user, IList<string> roles)
=> new()
{
AccessToken = accessToken,
ExpiresIn = 15 * 60,
User = new UserInfo
{
Id = user.Id,
Email = user.Email!,
Roles = roles,
LanguagePreference = user.LanguagePreference,
},
User = await BuildUserInfoAsync(user, roles),
};
/// <summary>Builds UserInfo including the effective permission map. Reused by /me.</summary>
public async Task<UserInfo> BuildUserInfoAsync(AppUser user, IList<string> roles)
=> new()
{
Id = user.Id,
Email = user.Email!,
Roles = roles,
LanguagePreference = user.LanguagePreference,
Permissions = await _permissions.GetEffectivePermissionsAsync(roles),
};
}
+15 -2
View File
@@ -4,7 +4,9 @@ using ROLAC.API.Data;
using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.DTOs.Shared;
using ROLAC.API.Entities;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Disbursement;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Storage;
namespace ROLAC.API.Services;
@@ -15,10 +17,11 @@ public class DisbursementService : IDisbursementService
private readonly IHttpContextAccessor _http;
private readonly IFileStorage _storage;
private readonly ICheckPrintService _print;
private readonly IAuditLogger _audit;
public DisbursementService(AppDbContext db, IHttpContextAccessor http,
IFileStorage storage, ICheckPrintService print)
{ _db = db; _http = http; _storage = storage; _print = print; }
IFileStorage storage, ICheckPrintService print, IAuditLogger audit)
{ _db = db; _http = http; _storage = storage; _print = print; _audit = audit; }
// The JWT carries the user id in the "sub" claim (NameClaimType="sub"); NameIdentifier
// is absent at runtime. Check NameIdentifier first (tests), then "sub" (real tokens).
@@ -157,6 +160,11 @@ public class DisbursementService : IDisbursementService
result.Created.Add(new IssuedCheckDto
{ CheckId = check.Id, CheckNumber = checkNumber, PayeeName = p.PayeeName, Amount = amount });
_audit.Write(
AuditActions.CheckIssued, AuditCategories.Business, LogLevelEnum.Information,
entityName: nameof(Check), entityId: check.Id.ToString(),
summary: $"Check #{checkNumber} issued to {p.PayeeName} — {amount:C}");
}
await tx.CommitAsync();
@@ -227,6 +235,11 @@ public class DisbursementService : IDisbursementService
}
await _db.SaveChangesAsync();
await tx.CommitAsync();
_audit.Write(
AuditActions.CheckVoided, AuditCategories.Business, LogLevelEnum.Warning,
entityName: nameof(Check), entityId: c.Id.ToString(),
summary: $"Check #{c.CheckNumber} voided ({reason})");
}
// ── Receipt e-signature ─────────────────────────────────────────────────────
+14 -6
View File
@@ -4,6 +4,8 @@ using ROLAC.API.Data;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.DTOs.Shared;
using ROLAC.API.Entities;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Storage;
namespace ROLAC.API.Services;
@@ -13,9 +15,10 @@ public class ExpenseService : IExpenseService
private readonly AppDbContext _db;
private readonly IHttpContextAccessor _http;
private readonly IFileStorage _storage;
private readonly IAuditLogger _audit;
public ExpenseService(AppDbContext db, IHttpContextAccessor http, IFileStorage storage)
{ _db = db; _http = http; _storage = storage; }
public ExpenseService(AppDbContext db, IHttpContextAccessor http, IFileStorage storage, IAuditLogger audit)
{ _db = db; _http = http; _storage = storage; _audit = audit; }
// The JWT carries the user id in the "sub" claim (NameClaimType="sub", MapInboundClaims=false),
// so ClaimTypes.NameIdentifier is absent at runtime. Check NameIdentifier first (unit tests set it),
@@ -171,8 +174,8 @@ public class ExpenseService : IExpenseService
// FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies.
var e = await _db.Expenses.FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"Expense {id} not found.");
if (!isFinance && !(e.SubmittedBy == CurrentUserId && e.Status == "Draft"))
throw new InvalidOperationException("You can only edit your own draft reimbursements.");
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval")))
throw new InvalidOperationException("You can only edit your own draft or pending reimbursements.");
e.MinistryId = r.MinistryId; e.CategoryGroupId = r.CategoryGroupId; e.SubCategoryId = r.SubCategoryId;
e.Amount = r.Amount; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
@@ -211,6 +214,11 @@ public class ExpenseService : IExpenseService
if (e.Status != "PendingApproval") throw new InvalidOperationException($"Cannot approve from status '{e.Status}'.");
e.Status = "Approved"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync();
_audit.Write(
AuditActions.ExpenseApproved, AuditCategories.Business, LogLevelEnum.Information,
entityName: nameof(Expense), entityId: e.Id.ToString(),
summary: $"Expense #{e.Id} approved: {e.Description} — {e.Amount:C}");
}
public async Task RejectAsync(int id, string? reviewNotes)
@@ -237,8 +245,8 @@ public class ExpenseService : IExpenseService
public async Task SaveReceiptAsync(int id, Stream content, string fileName, bool isFinance)
{
var e = await RequireAsync(id);
if (!isFinance && e.SubmittedBy != CurrentUserId)
throw new InvalidOperationException("You can only attach receipts to your own reimbursements.");
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval")))
throw new InvalidOperationException("You can only attach receipts to your own draft or pending reimbursements.");
var safe = Path.GetFileName(fileName).Replace(' ', '_');
var path = $"finance/receipts/{e.ExpenseDate.Year}/{e.ExpenseDate.Month}/{e.Id}-{safe}";
+23
View File
@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Identity;
using ROLAC.API.DTOs.Auth;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
@@ -28,4 +30,25 @@ public interface IAuthService
/// Silently succeeds if the token is not found.
/// </summary>
Task LogoutAsync(string rawRefreshToken);
/// <summary>
/// Changes the password for an already-authenticated user. Verifies the current
/// password and enforces the configured Identity password policy via
/// <c>UserManager.ChangePasswordAsync</c>. On success, revokes the user's other
/// active refresh tokens (keeping the one matching <paramref name="currentRawRefreshToken"/>)
/// and writes a security audit entry. Returns the <see cref="IdentityResult"/> so the
/// caller can surface failures; never throws on a bad password.
/// </summary>
Task<IdentityResult> ChangePasswordAsync(
string userId,
string currentPassword,
string newPassword,
string? currentRawRefreshToken);
/// <summary>
/// Builds the UserInfo payload (identity, roles, and effective permissions) for an
/// already-authenticated user. Used by GET /api/auth/me to refresh permissions
/// after an admin edits the matrix, without forcing a re-login.
/// </summary>
Task<UserInfo> BuildUserInfoAsync(AppUser user, IList<string> roles);
}
@@ -5,7 +5,7 @@ namespace ROLAC.API.Services;
public interface IMealAttendanceService
{
/// <summary>Today's date in the server's local time zone (the church's "current Sunday").</summary>
DateOnly Today { get; }
DateOnly ServiceDay { get; }
/// <summary>Returns the counts for <paramref name="date"/>, creating a zeroed row if none exists.</summary>
Task<AttendanceCountsDto> GetOrCreateAsync(DateOnly date);
@@ -0,0 +1,71 @@
using System.Text.Json;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// Serializes audit before/after payloads to JSON and masks sensitive property names.
/// Shared by <see cref="AuditLogger"/> and the EF audit interceptor so masking is consistent.
/// </summary>
public static class AuditChangeSerializer
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
};
/// <summary>Property names whose values are replaced with <see cref="MaskValue"/> wherever they appear.</summary>
private static readonly HashSet<string> SensitiveNames = new(StringComparer.OrdinalIgnoreCase)
{
"BankAccountNumber",
"BankRoutingNumber",
"PasswordHash",
"Password",
"SecurityStamp",
"ConcurrencyStamp",
};
public const string MaskValue = "***";
public static bool IsSensitive(string propertyName) => SensitiveNames.Contains(propertyName);
/// <summary>Builds the <c>{ before, after }</c> JSON; returns null when both sides are empty.</summary>
public static string? BuildChanges(object? before, object? after)
{
if (before is null && after is null)
return null;
var payload = new Dictionary<string, object?>();
if (before is not null) payload["before"] = MaskObject(before);
if (after is not null) payload["after"] = MaskObject(after);
return JsonSerializer.Serialize(payload, JsonOptions);
}
/// <summary>Serializes a value (e.g. a property dictionary built by the interceptor) to JSON.</summary>
public static string Serialize(object value) => JsonSerializer.Serialize(value, JsonOptions);
/// <summary>
/// Masks a free-form object by reflecting over its public properties. Used for the explicit
/// IAuditLogger.Write path (the interceptor masks per-property as it builds its dictionary).
/// </summary>
private static object MaskObject(object value)
{
if (value is IDictionary<string, object?> dict)
{
var masked = new Dictionary<string, object?>();
foreach (var (key, val) in dict)
masked[key] = IsSensitive(key) ? MaskValue : val;
return masked;
}
var result = new Dictionary<string, object?>();
foreach (var prop in value.GetType().GetProperties())
{
if (!prop.CanRead || prop.GetIndexParameters().Length > 0)
continue;
result[prop.Name] = IsSensitive(prop.Name) ? MaskValue : prop.GetValue(value);
}
return result;
}
}
@@ -0,0 +1,101 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data.Logging;
using ROLAC.API.DTOs.Logging;
using ROLAC.API.DTOs.Shared;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
public interface IAuditLogQueryService
{
Task<PagedResult<AuditLogListItemDto>> GetPagedAsync(AuditLogQuery query);
Task<AuditLogDetailDto?> GetByIdAsync(long id);
AuditCatalogDto GetCatalog();
}
/// <summary>Read-only, paged access to the AuditLogs table via the dedicated LogDbContext.</summary>
public sealed class AuditLogQueryService : IAuditLogQueryService
{
private readonly LogDbContext _db;
public AuditLogQueryService(LogDbContext db) => _db = db;
public async Task<PagedResult<AuditLogListItemDto>> GetPagedAsync(AuditLogQuery query)
{
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 200);
var rows = _db.AuditLogs.AsNoTracking().AsQueryable();
if (query.From is not null) rows = rows.Where(l => l.Timestamp >= query.From);
if (query.To is not null) rows = rows.Where(l => l.Timestamp <= query.To);
if (query.MinLevel is not null) rows = rows.Where(l => l.Level >= query.MinLevel);
if (!string.IsNullOrWhiteSpace(query.Category)) rows = rows.Where(l => l.Category == query.Category);
if (!string.IsNullOrWhiteSpace(query.Action)) rows = rows.Where(l => l.Action == query.Action);
if (!string.IsNullOrWhiteSpace(query.EntityName)) rows = rows.Where(l => l.EntityName == query.EntityName);
if (!string.IsNullOrWhiteSpace(query.EntityId)) rows = rows.Where(l => l.EntityId == query.EntityId);
if (!string.IsNullOrWhiteSpace(query.UserId)) rows = rows.Where(l => l.UserId == query.UserId);
if (!string.IsNullOrWhiteSpace(query.Search))
{
var term = query.Search.Trim().ToLower();
rows = rows.Where(l =>
(l.Summary != null && l.Summary.ToLower().Contains(term)) ||
(l.EntityName != null && l.EntityName.ToLower().Contains(term)) ||
(l.UserEmail != null && l.UserEmail.ToLower().Contains(term)));
}
var total = await rows.CountAsync();
var items = await rows
.OrderByDescending(l => l.Timestamp)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(l => new AuditLogListItemDto
{
Id = l.Id,
Timestamp = l.Timestamp,
Level = l.Level.ToString(),
Action = l.Action,
Category = l.Category,
EntityName = l.EntityName,
EntityId = l.EntityId,
Summary = l.Summary,
UserId = l.UserId,
UserEmail = l.UserEmail,
})
.ToListAsync();
return new PagedResult<AuditLogListItemDto>
{
Items = items, TotalCount = total, Page = page, PageSize = pageSize,
};
}
public async Task<AuditLogDetailDto?> GetByIdAsync(long id)
{
return await _db.AuditLogs.AsNoTracking()
.Where(l => l.Id == id)
.Select(l => new AuditLogDetailDto
{
Id = l.Id,
Timestamp = l.Timestamp,
Level = l.Level.ToString(),
Action = l.Action,
Category = l.Category,
EntityName = l.EntityName,
EntityId = l.EntityId,
Summary = l.Summary,
UserId = l.UserId,
UserEmail = l.UserEmail,
Changes = l.Changes,
IpAddress = l.IpAddress,
CorrelationId = l.CorrelationId,
})
.FirstOrDefaultAsync();
}
public AuditCatalogDto GetCatalog() => new()
{
Categories = AuditCategories.All,
Actions = AuditActions.All,
Levels = Enum.GetNames<LogLevelEnum>(),
};
}
@@ -0,0 +1,52 @@
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// Scoped <see cref="IAuditLogger"/>: fills actor/request context from
/// <see cref="CurrentUserAccessor"/> and enqueues the row onto the shared queue (no direct DB
/// write, so it can't fail a business transaction or recurse through AppDbContext).
/// </summary>
public sealed class AuditLogger : IAuditLogger
{
private readonly SystemLogQueue _queue;
private readonly CurrentUserAccessor _currentUser;
public AuditLogger(SystemLogQueue queue, CurrentUserAccessor currentUser)
{
_queue = queue;
_currentUser = currentUser;
}
public void Write(
string action,
string category,
LogLevelEnum level = LogLevelEnum.Information,
string? entityName = null,
string? entityId = null,
string? summary = null,
object? before = null,
object? after = null,
string? userId = null,
string? userEmail = null,
string? ipAddress = null)
{
var log = new AuditLog
{
Timestamp = DateTimeOffset.UtcNow,
Level = level,
Action = action,
Category = category,
EntityName = entityName,
EntityId = entityId,
Summary = summary,
Changes = AuditChangeSerializer.BuildChanges(before, after),
UserId = userId ?? _currentUser.UserId,
UserEmail = userEmail ?? _currentUser.Email,
IpAddress = ipAddress ?? _currentUser.IpAddress,
CorrelationId = _currentUser.CorrelationId,
};
_queue.TryEnqueue(log);
}
}
@@ -0,0 +1,30 @@
using System.Security.Claims;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// One place to resolve the acting user + request context from the current HttpContext, so the
/// "sub" claim quirk (JWT uses NameClaimType="sub" + MapInboundClaims=false, leaving
/// ClaimTypes.NameIdentifier null) lives in a single spot. Used by the audit interceptor,
/// IAuditLogger, the exception middleware, and the timestamp-stamping interceptor.
/// </summary>
public sealed class CurrentUserAccessor
{
private readonly IHttpContextAccessor _http;
public CurrentUserAccessor(IHttpContextAccessor http) => _http = http;
/// <summary>The acting user id, or null when unauthenticated / off the request thread.</summary>
public string? UserId =>
_http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? _http.HttpContext?.User.FindFirstValue("sub");
/// <summary>The acting user id, or "system" for background/unauthenticated work.</summary>
public string UserIdOrSystem => UserId ?? "system";
public string? Email => _http.HttpContext?.User.FindFirstValue("email");
public string? IpAddress => _http.HttpContext?.Connection.RemoteIpAddress?.ToString();
public string? CorrelationId => _http.HttpContext?.TraceIdentifier;
}
@@ -0,0 +1,27 @@
using MsLogLevel = Microsoft.Extensions.Logging.LogLevel;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// Bound from configuration section <c>Logging:Database</c>. Controls what the DB sink persists.
/// </summary>
public sealed class DatabaseLoggerOptions
{
/// <summary>The minimum level actually written to the SystemLogs table. Default: Warning.</summary>
public MsLogLevel MinimumLevel { get; set; } = MsLogLevel.Warning;
/// <summary>
/// Category prefixes never persisted — prevents recursion and log-storms. The DB sink
/// excludes EF/Npgsql (their SQL firehose), the ASP.NET request firehose, and its own
/// writer namespace.
/// </summary>
public string[] ExcludedCategories { get; set; } =
[
"Microsoft.EntityFrameworkCore",
"Npgsql",
"Microsoft.AspNetCore.Hosting.Diagnostics",
"Microsoft.AspNetCore.Routing",
"ROLAC.API.Services.Logging",
"ROLAC.API.Data.Logging",
];
}
@@ -0,0 +1,87 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using ROLAC.API.Entities.Logging;
using MsLogLevel = Microsoft.Extensions.Logging.LogLevel;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// A singleton <see cref="ILoggerProvider"/> that turns framework/app log events into
/// <see cref="SystemLog"/> rows enqueued onto <see cref="SystemLogQueue"/>. It depends only on
/// singletons (queue, options, IHttpContextAccessor) and NEVER touches a DbContext — that is
/// what makes the enqueue-only design safe from the singleton logging pipeline.
/// </summary>
[ProviderAlias("Database")]
public sealed class DbLoggerProvider : ILoggerProvider
{
private readonly SystemLogQueue _queue;
private readonly DatabaseLoggerOptions _options;
private readonly IHttpContextAccessor _http;
private readonly ConcurrentDictionary<string, DbLogger> _loggers = new();
public DbLoggerProvider(
SystemLogQueue queue, IOptions<DatabaseLoggerOptions> options, IHttpContextAccessor http)
{
_queue = queue;
_options = options.Value;
_http = http;
}
public ILogger CreateLogger(string categoryName) =>
_loggers.GetOrAdd(categoryName, name => new DbLogger(name, _queue, _options, _http));
public void Dispose() => _loggers.Clear();
}
/// <summary>The per-category logger. Drops events below the floor or in excluded categories.</summary>
internal sealed class DbLogger : ILogger
{
private readonly string _category;
private readonly SystemLogQueue _queue;
private readonly DatabaseLoggerOptions _options;
private readonly IHttpContextAccessor _http;
private readonly bool _excluded;
public DbLogger(
string category, SystemLogQueue queue, DatabaseLoggerOptions options, IHttpContextAccessor http)
{
_category = category;
_queue = queue;
_options = options;
_http = http;
_excluded = options.ExcludedCategories.Any(prefix =>
category.StartsWith(prefix, StringComparison.Ordinal));
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(MsLogLevel logLevel) =>
!_excluded && logLevel != MsLogLevel.None && logLevel >= _options.MinimumLevel;
public void Log<TState>(
MsLogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel))
return;
var context = _http.HttpContext;
var log = new SystemLog
{
Timestamp = DateTimeOffset.UtcNow,
Level = LogLevelMap.FromMs(logLevel),
Category = _category,
EventId = eventId.Id == 0 ? null : eventId.Id,
Message = formatter(state, exception),
Exception = exception?.ToString(),
RequestPath = context?.Request.Path.Value,
HttpMethod = context?.Request.Method,
UserId = context?.User.FindFirst("sub")?.Value,
IpAddress = context?.Connection.RemoteIpAddress?.ToString(),
CorrelationId = context?.TraceIdentifier,
};
_queue.TryEnqueue(log);
}
}
@@ -0,0 +1,29 @@
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// Records audit events that don't flow through EF change tracking — security actions
/// (login/logout/role changes) and key business actions (check issued, expense approved, ...).
/// Data-change audits are produced automatically by <c>AuditLogInterceptor</c>; use this for the
/// semantic action + human summary the raw diff can't express.
/// </summary>
public interface IAuditLogger
{
/// <summary>
/// Build and enqueue an audit row. <paramref name="before"/>/<paramref name="after"/> are
/// serialized into the <c>Changes</c> JSON. Never throws — failures are dropped like all logs.
/// </summary>
void Write(
string action,
string category,
LogLevelEnum level = LogLevelEnum.Information,
string? entityName = null,
string? entityId = null,
string? summary = null,
object? before = null,
object? after = null,
string? userId = null,
string? userEmail = null,
string? ipAddress = null);
}
@@ -0,0 +1,102 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data.Logging;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// The single consumer that drains <see cref="SystemLogQueue"/> and batch-inserts rows through
/// the dedicated <see cref="LogDbContext"/> (a fresh DI scope per batch). Persistence failures
/// are swallowed to <c>Console.Error</c> only — they must never propagate back into the logging
/// pipeline or crash the host.
/// </summary>
public sealed class LogWriterBackgroundService : BackgroundService
{
private const int MaxBatchSize = 200;
private static readonly TimeSpan FlushInterval = TimeSpan.FromSeconds(1);
private readonly SystemLogQueue _queue;
private readonly IServiceScopeFactory _scopeFactory;
public LogWriterBackgroundService(SystemLogQueue queue, IServiceScopeFactory scopeFactory)
{
_queue = queue;
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var systemBatch = new List<SystemLog>(MaxBatchSize);
var auditBatch = new List<AuditLog>(MaxBatchSize);
try
{
await foreach (var envelope in _queue.ReadAllAsync(stoppingToken))
{
if (envelope.System is not null) systemBatch.Add(envelope.System);
if (envelope.Audit is not null) auditBatch.Add(envelope.Audit);
// Coalesce a short burst into one round-trip; flush on size or a brief idle.
if (systemBatch.Count + auditBatch.Count >= MaxBatchSize)
{
await FlushAsync(systemBatch, auditBatch, stoppingToken);
continue;
}
if (!await WaitForMoreAsync(FlushInterval, stoppingToken))
await FlushAsync(systemBatch, auditBatch, stoppingToken);
}
}
catch (OperationCanceledException)
{
// Shutting down — drain whatever is buffered.
}
await FlushAsync(systemBatch, auditBatch, CancellationToken.None);
}
/// <summary>Brief debounce so bursts coalesce; returns false once the window elapses.</summary>
private static async Task<bool> WaitForMoreAsync(TimeSpan window, CancellationToken token)
{
try
{
await Task.Delay(window, token);
return false;
}
catch (OperationCanceledException)
{
return false;
}
}
private async Task FlushAsync(
List<SystemLog> systemBatch, List<AuditLog> auditBatch, CancellationToken token)
{
if (systemBatch.Count == 0 && auditBatch.Count == 0)
return;
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<LogDbContext>();
if (systemBatch.Count > 0) db.SystemLogs.AddRange(systemBatch);
if (auditBatch.Count > 0) db.AuditLogs.AddRange(auditBatch);
await db.SaveChangesAsync(token);
}
catch (Exception ex)
{
// Last resort: never throw out of the log writer. Include the inner exception —
// the EF wrapper message alone ("An error occurred while saving...") hides the cause.
var detail = ex.InnerException is null ? ex.Message : $"{ex.Message} -> {ex.InnerException.Message}";
await Console.Error.WriteLineAsync(
$"[LogWriter] Failed to persist {systemBatch.Count} system + {auditBatch.Count} audit rows: {detail}");
}
finally
{
systemBatch.Clear();
auditBatch.Clear();
}
}
}
@@ -0,0 +1,93 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data.Logging;
using ROLAC.API.DTOs.Logging;
using ROLAC.API.DTOs.Shared;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
public interface ISystemLogQueryService
{
Task<PagedResult<SystemLogListItemDto>> GetPagedAsync(SystemLogQuery query);
Task<SystemLogDetailDto?> GetByIdAsync(long id);
}
/// <summary>Read-only, paged access to the SystemLogs table via the dedicated LogDbContext.</summary>
public sealed class SystemLogQueryService : ISystemLogQueryService
{
private readonly LogDbContext _db;
public SystemLogQueryService(LogDbContext db) => _db = db;
public async Task<PagedResult<SystemLogListItemDto>> GetPagedAsync(SystemLogQuery query)
{
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 200);
var rows = _db.SystemLogs.AsNoTracking().AsQueryable();
if (query.From is not null) rows = rows.Where(l => l.Timestamp >= query.From);
if (query.To is not null) rows = rows.Where(l => l.Timestamp <= query.To);
if (query.Level is not null) rows = rows.Where(l => l.Level == query.Level);
else if (query.MinLevel is not null) rows = rows.Where(l => l.Level >= query.MinLevel);
if (!string.IsNullOrWhiteSpace(query.UserId))
rows = rows.Where(l => l.UserId == query.UserId);
if (!string.IsNullOrWhiteSpace(query.CorrelationId))
rows = rows.Where(l => l.CorrelationId == query.CorrelationId);
if (!string.IsNullOrWhiteSpace(query.Search))
{
var term = query.Search.Trim().ToLower();
rows = rows.Where(l =>
l.Message.ToLower().Contains(term) || l.Category.ToLower().Contains(term));
}
var total = await rows.CountAsync();
var items = await rows
.OrderByDescending(l => l.Timestamp)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(l => new SystemLogListItemDto
{
Id = l.Id,
Timestamp = l.Timestamp,
Level = l.Level.ToString(),
Category = l.Category,
Message = l.Message,
HasException = l.Exception != null,
StatusCode = l.StatusCode,
RequestPath = l.RequestPath,
HttpMethod = l.HttpMethod,
UserId = l.UserId,
CorrelationId = l.CorrelationId,
})
.ToListAsync();
return new PagedResult<SystemLogListItemDto>
{
Items = items, TotalCount = total, Page = page, PageSize = pageSize,
};
}
public async Task<SystemLogDetailDto?> GetByIdAsync(long id)
{
return await _db.SystemLogs.AsNoTracking()
.Where(l => l.Id == id)
.Select(l => new SystemLogDetailDto
{
Id = l.Id,
Timestamp = l.Timestamp,
Level = l.Level.ToString(),
Category = l.Category,
Message = l.Message,
HasException = l.Exception != null,
StatusCode = l.StatusCode,
RequestPath = l.RequestPath,
HttpMethod = l.HttpMethod,
UserId = l.UserId,
CorrelationId = l.CorrelationId,
EventId = l.EventId,
Exception = l.Exception,
IpAddress = l.IpAddress,
})
.FirstOrDefaultAsync();
}
}
@@ -0,0 +1,32 @@
using System.Threading.Channels;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// A singleton, bounded in-memory queue decoupling log producers (the ILogger hot path, the
/// audit interceptor, singleton services) from the single background DB writer. Enqueue is a
/// non-blocking <c>TryWrite</c>; when full the OLDEST entry is dropped (DropWrite) rather than
/// blocking a request thread or throwing — logging must never throw or stall business code.
/// Carries both SystemLog and AuditLog rows via a small union envelope.
/// </summary>
public sealed class SystemLogQueue
{
private readonly Channel<LogEnvelope> _channel =
Channel.CreateBounded<LogEnvelope>(new BoundedChannelOptions(capacity: 4096)
{
FullMode = BoundedChannelFullMode.DropWrite,
SingleReader = true,
SingleWriter = false,
});
public bool TryEnqueue(SystemLog log) => _channel.Writer.TryWrite(new LogEnvelope(log, null));
public bool TryEnqueue(AuditLog log) => _channel.Writer.TryWrite(new LogEnvelope(null, log));
public IAsyncEnumerable<LogEnvelope> ReadAllAsync(CancellationToken cancellationToken) =>
_channel.Reader.ReadAllAsync(cancellationToken);
}
/// <summary>Either a SystemLog or an AuditLog — exactly one is non-null.</summary>
public sealed record LogEnvelope(SystemLog? System, AuditLog? Audit);
@@ -12,7 +12,14 @@ public class MealAttendanceService : IMealAttendanceService
public MealAttendanceService(AppDbContext db) => _db = db;
// Server local time is assumed to match the church's local day.
public DateOnly Today => DateOnly.FromDateTime(DateTime.Now);
public DateOnly ServiceDay
{
get
{
var today = DateOnly.FromDateTime(DateTime.Now);
return today.AddDays(-(int)today.DayOfWeek);
}
}
public async Task<AttendanceCountsDto> GetOrCreateAsync(DateOnly date)
{

Some files were not shown because too many files have changed in this diff Show More