Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a121f6085 | |||
| 5a25b33258 | |||
| b0deb62c82 | |||
| a2ecc895de | |||
| 1e6ddddf1f | |||
| c54adf1eda | |||
| 5e0348de1d | |||
| 8f18166dbf | |||
| 8f1af536ed | |||
| 180dea60c1 | |||
| 9df391b42c | |||
| 4225b49e58 | |||
| 5a915ebdd1 | |||
| fd71f5a107 | |||
| 9405914d88 | |||
| 39432ac588 | |||
| 4c22cfaf19 | |||
| c8bc7103ba | |||
| 3eeb314dc2 | |||
| 0ddb34dd20 | |||
| 444cc70b56 | |||
| 85bf329d93 | |||
| 3544b6ee78 | |||
| 0e90f19377 | |||
| f9c4d7edb2 | |||
| b7372dec1f | |||
| 21e9823008 | |||
| 583408032d | |||
| ea0ea233a8 | |||
| 7356d0e810 | |||
| b1e3e23325 | |||
| a298d0ee1c | |||
| 249ae1164d | |||
| c6e3f1db64 | |||
| bd722933dc | |||
| f6277aa339 | |||
| 2e226e60f5 | |||
| 68649223d9 | |||
| 9d7c224ad2 | |||
| 47aec287aa | |||
| 5dfca873dd | |||
| 62592c29ae | |||
| 870eeec82a |
@@ -50,3 +50,36 @@ jobs:
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
sleep 5
|
sleep 5
|
||||||
curl -fsS http://localhost:8080/api/health
|
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
|
||||||
|
|||||||
@@ -52,3 +52,41 @@ jobs:
|
|||||||
docker compose pull
|
docker compose pull
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
curl -fsS https://manage.rolac.org/api/health
|
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 ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
|
||||||
var mock = new Mock<IHttpContextAccessor>();
|
var mock = new Mock<IHttpContextAccessor>();
|
||||||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
return new AuditSaveChangesInterceptor(mock.Object);
|
return new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration;
|
|||||||
using Moq;
|
using Moq;
|
||||||
using ROLAC.API.Data;
|
using ROLAC.API.Data;
|
||||||
using ROLAC.API.DTOs.Auth;
|
using ROLAC.API.DTOs.Auth;
|
||||||
|
using ROLAC.API.DTOs.Permissions;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -33,7 +34,8 @@ public class AuthServiceTests
|
|||||||
private static Mock<UserManager<AppUser>> BuildUserManager(
|
private static Mock<UserManager<AppUser>> BuildUserManager(
|
||||||
AppUser? findResult = null,
|
AppUser? findResult = null,
|
||||||
bool passwordOk = true,
|
bool passwordOk = true,
|
||||||
IList<string>? roles = null)
|
IList<string>? roles = null,
|
||||||
|
IdentityResult? changePasswordResult = null)
|
||||||
{
|
{
|
||||||
var store = new Mock<IUserStore<AppUser>>();
|
var store = new Mock<IUserStore<AppUser>>();
|
||||||
// Remaining ctor params are all optional; Moq passes them via reflection.
|
// Remaining ctor params are all optional; Moq passes them via reflection.
|
||||||
@@ -52,6 +54,9 @@ public class AuthServiceTests
|
|||||||
.ReturnsAsync(roles ?? new List<string> { "member" });
|
.ReturnsAsync(roles ?? new List<string> { "member" });
|
||||||
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
|
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
|
||||||
.ReturnsAsync(IdentityResult.Success);
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
|
mgr.Setup(m => m.ChangePasswordAsync(
|
||||||
|
It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(changePasswordResult ?? IdentityResult.Success);
|
||||||
|
|
||||||
return mgr;
|
return mgr;
|
||||||
}
|
}
|
||||||
@@ -72,11 +77,21 @@ public class AuthServiceTests
|
|||||||
return svc;
|
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(
|
private static AuthService BuildSut(
|
||||||
Mock<UserManager<AppUser>> umMock,
|
Mock<UserManager<AppUser>> umMock,
|
||||||
Mock<ITokenService> tsMock,
|
Mock<ITokenService> tsMock,
|
||||||
AppDbContext db)
|
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
|
// Login tests
|
||||||
@@ -255,4 +270,85 @@ public class AuthServiceTests
|
|||||||
var token = db.RefreshTokens.Single();
|
var token = db.RefreshTokens.Single();
|
||||||
Assert.NotNull(token.RevokedAt);
|
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>()
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
.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)
|
private static DisbursementService SvcAs(AppDbContext db, FakeStorage fs, string userId)
|
||||||
@@ -57,7 +57,7 @@ public class DisbursementServiceTests
|
|||||||
var http = new Mock<IHttpContextAccessor>();
|
var http = new Mock<IHttpContextAccessor>();
|
||||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
|
||||||
http.Setup(x => x.HttpContext).Returns(ctx);
|
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")
|
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 http = new Mock<IHttpContextAccessor>();
|
||||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
|
||||||
http.Setup(x => x.HttpContext).Returns(ctx);
|
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]
|
[Fact]
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public class ExpenseCategoryServiceTests
|
|||||||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options);
|
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ public class ExpenseServiceTests
|
|||||||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.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")
|
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 http = new Mock<IHttpContextAccessor>();
|
||||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
|
||||||
http.Setup(x => x.HttpContext).Returns(ctx);
|
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),
|
// 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 http = new Mock<IHttpContextAccessor>();
|
||||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim("sub", userId) })) };
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim("sub", userId) })) };
|
||||||
http.Setup(x => x.HttpContext).Returns(ctx);
|
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()
|
private static CreateExpenseRequest Reimb() => new()
|
||||||
@@ -197,6 +197,48 @@ public class ExpenseServiceTests
|
|||||||
bobSvc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false));
|
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]
|
[Fact]
|
||||||
public async Task SoftDelete_HidesFromQueries()
|
public async Task SoftDelete_HidesFromQueries()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public class GivingCategoryServiceTests
|
|||||||
|
|
||||||
private static AppDbContext BuildDb(string userId = "test-user")
|
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(
|
return new AppDbContext(
|
||||||
new DbContextOptionsBuilder<AppDbContext>()
|
new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public class GivingServiceTests
|
|||||||
|
|
||||||
private static AppDbContext BuildDb(string userId = "test-user")
|
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(
|
return new AppDbContext(
|
||||||
new DbContextOptionsBuilder<AppDbContext>()
|
new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public class MemberServiceTests
|
|||||||
private static AppDbContext BuildDb(string userId = "test-user")
|
private static AppDbContext BuildDb(string userId = "test-user")
|
||||||
{
|
{
|
||||||
var accessor = BuildAccessor(userId);
|
var accessor = BuildAccessor(userId);
|
||||||
var interceptor = new AuditSaveChangesInterceptor(accessor);
|
var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(accessor));
|
||||||
return new AppDbContext(
|
return new AppDbContext(
|
||||||
new DbContextOptionsBuilder<AppDbContext>()
|
new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public class MinistryServiceTests
|
|||||||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options);
|
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public class MonthlyStatementServiceTests
|
|||||||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.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)
|
private static MonthlyStatementService Build(AppDbContext db)
|
||||||
@@ -29,7 +29,7 @@ public class MonthlyStatementServiceTests
|
|||||||
var mock = new Mock<IHttpContextAccessor>();
|
var mock = new Mock<IHttpContextAccessor>();
|
||||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) };
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) };
|
||||||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
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]
|
[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")
|
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(
|
return new AppDbContext(
|
||||||
new DbContextOptionsBuilder<AppDbContext>()
|
new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.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)
|
mgr.Setup(m => m.Users)
|
||||||
.Returns(new List<AppUser>().AsQueryable());
|
.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
|
var result = await svc.CreateAsync(new CreateUserRequest
|
||||||
{
|
{
|
||||||
MemberId = member.Id,
|
MemberId = member.Id,
|
||||||
@@ -97,7 +97,7 @@ public class UserManagementServiceTests
|
|||||||
var mgr = BuildUserManager();
|
var mgr = BuildUserManager();
|
||||||
mgr.Setup(m => m.Users)
|
mgr.Setup(m => m.Users)
|
||||||
.Returns(new List<AppUser>().AsQueryable());
|
.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>(() =>
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
svc.CreateAsync(new CreateUserRequest
|
svc.CreateAsync(new CreateUserRequest
|
||||||
@@ -131,7 +131,7 @@ public class UserManagementServiceTests
|
|||||||
// The service checks _userManager.Users — we need to return the existing user
|
// The service checks _userManager.Users — we need to return the existing user
|
||||||
mgr.Setup(m => m.Users)
|
mgr.Setup(m => m.Users)
|
||||||
.Returns(new List<AppUser> { existingUser }.AsQueryable());
|
.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>(() =>
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
svc.CreateAsync(new CreateUserRequest
|
svc.CreateAsync(new CreateUserRequest
|
||||||
@@ -147,7 +147,7 @@ public class UserManagementServiceTests
|
|||||||
var user = new AppUser
|
var user = new AppUser
|
||||||
{ Id = "u1", UserName = "a@b.com", Email = "a@b.com", IsActive = true };
|
{ Id = "u1", UserName = "a@b.com", Email = "a@b.com", IsActive = true };
|
||||||
var mgr = BuildUserManager(findResult: user);
|
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");
|
await svc.DeactivateAsync("u1");
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ public class UserManagementServiceTests
|
|||||||
{
|
{
|
||||||
using var db = BuildDb();
|
using var db = BuildDb();
|
||||||
var mgr = BuildUserManager(findResult: null);
|
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"));
|
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeactivateAsync("missing"));
|
||||||
}
|
}
|
||||||
@@ -173,7 +173,7 @@ public class UserManagementServiceTests
|
|||||||
using var db = BuildDb();
|
using var db = BuildDb();
|
||||||
var user = new AppUser { Id = "u1", UserName = "a@b.com", Email = "a@b.com" };
|
var user = new AppUser { Id = "u1", UserName = "a@b.com", Email = "a@b.com" };
|
||||||
var mgr = BuildUserManager(findResult: user);
|
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");
|
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:<module>:<action></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)..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:<module>:<action></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:<module>:<action></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());
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using ROLAC.API.DTOs.Auth;
|
using ROLAC.API.DTOs.Auth;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
namespace ROLAC.API.Controllers;
|
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 const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
|
||||||
|
|
||||||
private readonly IAuthService _authService;
|
private readonly IAuthService _authService;
|
||||||
|
private readonly UserManager<AppUser> _userManager;
|
||||||
private readonly IWebHostEnvironment _env;
|
private readonly IWebHostEnvironment _env;
|
||||||
|
|
||||||
public AuthController(IAuthService authService, IWebHostEnvironment env)
|
public AuthController(
|
||||||
|
IAuthService authService, UserManager<AppUser> userManager, IWebHostEnvironment env)
|
||||||
{
|
{
|
||||||
_authService = authService;
|
_authService = authService;
|
||||||
|
_userManager = userManager;
|
||||||
_env = env;
|
_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>
|
/// <summary>
|
||||||
/// Returns the claims ASP.NET Core parsed from the Bearer token.
|
/// Returns the current user's identity, roles, and effective permissions.
|
||||||
/// Use this to debug 401 vs 403: if you get 200 here, the JWT validates
|
/// The SPA calls this on startup and after an admin edits the permission matrix
|
||||||
/// fine; if you then get 403 on /api/users the role claim isn't matching.
|
/// to refresh what the UI shows — without forcing a re-login.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("me")]
|
[HttpGet("me")]
|
||||||
[Authorize] // no role restriction — just needs a valid JWT
|
[Authorize]
|
||||||
public IActionResult GetMe()
|
[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
|
var claims = User.Claims
|
||||||
.Select(c => new { c.Type, c.Value })
|
.Select(c => new { c.Type, c.Value })
|
||||||
@@ -122,6 +154,38 @@ public class AuthController : ControllerBase
|
|||||||
return NoContent();
|
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
|
// Private helpers
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Disbursement;
|
using ROLAC.API.DTOs.Disbursement;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -7,16 +8,18 @@ namespace ROLAC.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/church-profile")]
|
[Route("api/church-profile")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[Authorize]
|
||||||
public class ChurchProfileController : ControllerBase
|
public class ChurchProfileController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IChurchProfileService _svc;
|
private readonly IChurchProfileService _svc;
|
||||||
public ChurchProfileController(IChurchProfileService svc) => _svc = svc;
|
public ChurchProfileController(IChurchProfileService svc) => _svc = svc;
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[HasPermission(Modules.ChurchProfile, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> Get() => Ok(await _svc.GetAsync());
|
public async Task<IActionResult> Get() => Ok(await _svc.GetAsync());
|
||||||
|
|
||||||
[HttpPut]
|
[HttpPut]
|
||||||
|
[HasPermission(Modules.ChurchProfile, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Update([FromBody] UpdateChurchProfileRequest r)
|
public async Task<IActionResult> Update([FromBody] UpdateChurchProfileRequest r)
|
||||||
{
|
{
|
||||||
await _svc.UpdateAsync(r);
|
await _svc.UpdateAsync(r);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Disbursement;
|
using ROLAC.API.DTOs.Disbursement;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -8,17 +9,19 @@ namespace ROLAC.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/disbursements")]
|
[Route("api/disbursements")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[Authorize]
|
||||||
public class DisbursementsController : ControllerBase
|
public class DisbursementsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IDisbursementService _svc;
|
private readonly IDisbursementService _svc;
|
||||||
public DisbursementsController(IDisbursementService svc) => _svc = svc;
|
public DisbursementsController(IDisbursementService svc) => _svc = svc;
|
||||||
|
|
||||||
[HttpGet("approved-unpaid")]
|
[HttpGet("approved-unpaid")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetApprovedUnpaid()
|
public async Task<IActionResult> GetApprovedUnpaid()
|
||||||
=> Ok(await _svc.GetApprovedUnpaidGroupedAsync());
|
=> Ok(await _svc.GetApprovedUnpaidGroupedAsync());
|
||||||
|
|
||||||
[HttpPost("issue")]
|
[HttpPost("issue")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Issue([FromBody] IssueChecksRequest r)
|
public async Task<IActionResult> Issue([FromBody] IssueChecksRequest r)
|
||||||
{
|
{
|
||||||
try { return Ok(await _svc.IssueChecksAsync(r)); }
|
try { return Ok(await _svc.IssueChecksAsync(r)); }
|
||||||
@@ -27,12 +30,14 @@ public class DisbursementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("checks")]
|
[HttpGet("checks")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetRegister(
|
public async Task<IActionResult> GetRegister(
|
||||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? status = null,
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? status = null,
|
||||||
[FromQuery] string? search = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
[FromQuery] string? search = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
||||||
=> Ok(await _svc.GetRegisterAsync(page, pageSize, status, search, from, to));
|
=> Ok(await _svc.GetRegisterAsync(page, pageSize, status, search, from, to));
|
||||||
|
|
||||||
[HttpGet("checks/{id:int}")]
|
[HttpGet("checks/{id:int}")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetById(int id)
|
public async Task<IActionResult> GetById(int id)
|
||||||
{
|
{
|
||||||
var dto = await _svc.GetByIdAsync(id);
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
@@ -40,6 +45,7 @@ public class DisbursementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("checks/{id:int}/void")]
|
[HttpPost("checks/{id:int}/void")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> Void(int id, [FromBody] VoidCheckRequest r)
|
public async Task<IActionResult> Void(int id, [FromBody] VoidCheckRequest r)
|
||||||
{
|
{
|
||||||
try { await _svc.VoidAsync(id, r.Reason); return NoContent(); }
|
try { await _svc.VoidAsync(id, r.Reason); return NoContent(); }
|
||||||
@@ -48,6 +54,7 @@ public class DisbursementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("checks/{id:int}/pdf")]
|
[HttpGet("checks/{id:int}/pdf")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetPdf(int id)
|
public async Task<IActionResult> GetPdf(int id)
|
||||||
{
|
{
|
||||||
var result = await _svc.RenderPdfAsync(id);
|
var result = await _svc.RenderPdfAsync(id);
|
||||||
@@ -56,6 +63,7 @@ public class DisbursementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("checks/{id:int}/receipt-pdf")]
|
[HttpGet("checks/{id:int}/receipt-pdf")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetReceiptPdf(int id)
|
public async Task<IActionResult> GetReceiptPdf(int id)
|
||||||
{
|
{
|
||||||
var result = await _svc.RenderReceiptPdfAsync(id);
|
var result = await _svc.RenderReceiptPdfAsync(id);
|
||||||
@@ -64,6 +72,7 @@ public class DisbursementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("checks/{id:int}/acknowledge")]
|
[HttpPost("checks/{id:int}/acknowledge")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Approve)]
|
||||||
[RequestSizeLimit(5_242_880)]
|
[RequestSizeLimit(5_242_880)]
|
||||||
public async Task<IActionResult> Acknowledge(int id, [FromForm] IFormFile signature, [FromForm] string signedName)
|
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")]
|
[HttpGet("checks/{id:int}/signature")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetSignature(int id)
|
public async Task<IActionResult> GetSignature(int id)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Expense;
|
using ROLAC.API.DTOs.Expense;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -19,32 +20,32 @@ public class ExpenseCategoriesController : ControllerBase
|
|||||||
=> Ok(await _svc.GetAllAsync(includeInactive));
|
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||||
|
|
||||||
[HttpPost("groups")]
|
[HttpPost("groups")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)
|
public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)
|
||||||
=> Ok(new { id = await _svc.CreateGroupAsync(r) });
|
=> Ok(new { id = await _svc.CreateGroupAsync(r) });
|
||||||
|
|
||||||
[HttpPut("groups/{id:int}")]
|
[HttpPut("groups/{id:int}")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateExpenseGroupRequest r)
|
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateExpenseGroupRequest r)
|
||||||
{ try { await _svc.UpdateGroupAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
{ try { await _svc.UpdateGroupAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||||
|
|
||||||
[HttpDelete("groups/{id:int}")]
|
[HttpDelete("groups/{id:int}")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> DeactivateGroup(int id)
|
public async Task<IActionResult> DeactivateGroup(int id)
|
||||||
{ try { await _svc.DeactivateGroupAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
{ try { await _svc.DeactivateGroupAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||||
|
|
||||||
[HttpPost("subcategories")]
|
[HttpPost("subcategories")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> CreateSub([FromBody] CreateExpenseSubCategoryRequest r)
|
public async Task<IActionResult> CreateSub([FromBody] CreateExpenseSubCategoryRequest r)
|
||||||
{ try { return Ok(new { id = await _svc.CreateSubCategoryAsync(r) }); } catch (KeyNotFoundException) { return NotFound(); } }
|
{ try { return Ok(new { id = await _svc.CreateSubCategoryAsync(r) }); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||||
|
|
||||||
[HttpPut("subcategories/{id:int}")]
|
[HttpPut("subcategories/{id:int}")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> UpdateSub(int id, [FromBody] UpdateExpenseSubCategoryRequest r)
|
public async Task<IActionResult> UpdateSub(int id, [FromBody] UpdateExpenseSubCategoryRequest r)
|
||||||
{ try { await _svc.UpdateSubCategoryAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
{ try { await _svc.UpdateSubCategoryAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||||
|
|
||||||
[HttpDelete("subcategories/{id:int}")]
|
[HttpDelete("subcategories/{id:int}")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> DeactivateSub(int id)
|
public async Task<IActionResult> DeactivateSub(int id)
|
||||||
{ try { await _svc.DeactivateSubCategoryAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
{ try { await _svc.DeactivateSubCategoryAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,38 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Expense;
|
using ROLAC.API.DTOs.Expense;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
namespace ROLAC.API.Controllers;
|
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]
|
[ApiController]
|
||||||
[Route("api/expenses")]
|
[Route("api/expenses")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public class ExpensesController : ControllerBase
|
public class ExpensesController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IExpenseService _svc;
|
private readonly IExpenseService _svc;
|
||||||
public ExpensesController(IExpenseService svc) => _svc = 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 List<string> Roles() => User.FindAll("role").Select(claim => claim.Value).ToList();
|
||||||
private bool CanViewAll() => IsFinance() || User.IsInRole("pastor");
|
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.
|
// User id lives in the "sub" claim (NameClaimType="sub"); NameIdentifier is absent at runtime.
|
||||||
private string CurrentUserId() =>
|
private string CurrentUserId() =>
|
||||||
@@ -28,7 +45,7 @@ public class ExpensesController : ControllerBase
|
|||||||
[FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null,
|
[FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null,
|
||||||
[FromQuery] int? subCategoryId = null, [FromQuery] string? statuses = 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));
|
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);
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
if (dto is null) return NotFound();
|
if (dto is null) return NotFound();
|
||||||
if (!CanViewAll() && dto.SubmittedBy != CurrentUserId()) return Forbid();
|
if (!await CanViewAllAsync() && dto.SubmittedBy != CurrentUserId()) return Forbid();
|
||||||
return Ok(dto);
|
return Ok(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateExpenseRequest r)
|
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 }); }
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateExpenseRequest r)
|
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 (KeyNotFoundException) { return NotFound(); }
|
||||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
}
|
}
|
||||||
@@ -65,7 +82,7 @@ public class ExpensesController : ControllerBase
|
|||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
public async Task<IActionResult> Delete(int id)
|
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 (KeyNotFoundException) { return NotFound(); }
|
||||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
}
|
}
|
||||||
@@ -79,7 +96,7 @@ public class ExpensesController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:int}/approve")]
|
[HttpPost("{id:int}/approve")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
|
||||||
public async Task<IActionResult> Approve(int id)
|
public async Task<IActionResult> Approve(int id)
|
||||||
{
|
{
|
||||||
try { await _svc.ApproveAsync(id); return NoContent(); }
|
try { await _svc.ApproveAsync(id); return NoContent(); }
|
||||||
@@ -88,7 +105,7 @@ public class ExpensesController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:int}/reject")]
|
[HttpPost("{id:int}/reject")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
|
||||||
public async Task<IActionResult> Reject(int id, [FromBody] RejectExpenseRequest r)
|
public async Task<IActionResult> Reject(int id, [FromBody] RejectExpenseRequest r)
|
||||||
{
|
{
|
||||||
try { await _svc.RejectAsync(id, r.ReviewNotes); return NoContent(); }
|
try { await _svc.RejectAsync(id, r.ReviewNotes); return NoContent(); }
|
||||||
@@ -97,7 +114,7 @@ public class ExpensesController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:int}/pay")]
|
[HttpPost("{id:int}/pay")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
|
||||||
public async Task<IActionResult> Pay(int id, [FromBody] PayExpenseRequest r)
|
public async Task<IActionResult> Pay(int id, [FromBody] PayExpenseRequest r)
|
||||||
{
|
{
|
||||||
try { await _svc.PayAsync(id, r.CheckNumber, r.PaidAt); return NoContent(); }
|
try { await _svc.PayAsync(id, r.CheckNumber, r.PaidAt); return NoContent(); }
|
||||||
@@ -115,7 +132,7 @@ public class ExpensesController : ControllerBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var stream = file.OpenReadStream();
|
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();
|
return NoContent();
|
||||||
}
|
}
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
@@ -127,7 +144,7 @@ public class ExpensesController : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await _svc.OpenReceiptAsync(id, IsFinance());
|
var result = await _svc.OpenReceiptAsync(id, await CanManageAsync());
|
||||||
if (result is null) return NotFound();
|
if (result is null) return NotFound();
|
||||||
return File(result.Value.stream, result.Value.contentType);
|
return File(result.Value.stream, result.Value.contentType);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
namespace ROLAC.API.Controllers;
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/finance-dashboard")]
|
[Route("api/finance-dashboard")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.FinanceDashboard, PermissionActions.Read)]
|
||||||
public class FinanceDashboardController : ControllerBase
|
public class FinanceDashboardController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IFinanceDashboardService _svc;
|
private readonly IFinanceDashboardService _svc;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Giving;
|
using ROLAC.API.DTOs.Giving;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -7,17 +8,19 @@ namespace ROLAC.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/giving-categories")]
|
[Route("api/giving-categories")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[Authorize]
|
||||||
public class GivingCategoriesController : ControllerBase
|
public class GivingCategoriesController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IGivingCategoryService _svc;
|
private readonly IGivingCategoryService _svc;
|
||||||
public GivingCategoriesController(IGivingCategoryService svc) => _svc = svc;
|
public GivingCategoriesController(IGivingCategoryService svc) => _svc = svc;
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[HasPermission(Modules.GivingCategories, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
||||||
=> Ok(await _svc.GetAllAsync(includeInactive));
|
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.GivingCategories, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateGivingCategoryRequest request)
|
public async Task<IActionResult> Create([FromBody] CreateGivingCategoryRequest request)
|
||||||
{
|
{
|
||||||
var id = await _svc.CreateAsync(request);
|
var id = await _svc.CreateAsync(request);
|
||||||
@@ -25,6 +28,7 @@ public class GivingCategoriesController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
|
[HasPermission(Modules.GivingCategories, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingCategoryRequest request)
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingCategoryRequest request)
|
||||||
{
|
{
|
||||||
try { await _svc.UpdateAsync(id, request); return NoContent(); }
|
try { await _svc.UpdateAsync(id, request); return NoContent(); }
|
||||||
@@ -32,6 +36,7 @@ public class GivingCategoriesController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
|
[HasPermission(Modules.GivingCategories, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> Deactivate(int id)
|
public async Task<IActionResult> Deactivate(int id)
|
||||||
{
|
{
|
||||||
try { await _svc.DeactivateAsync(id); return NoContent(); }
|
try { await _svc.DeactivateAsync(id); return NoContent(); }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Giving;
|
using ROLAC.API.DTOs.Giving;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -7,13 +8,14 @@ namespace ROLAC.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/givings")]
|
[Route("api/givings")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[Authorize]
|
||||||
public class GivingsController : ControllerBase
|
public class GivingsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IGivingService _svc;
|
private readonly IGivingService _svc;
|
||||||
public GivingsController(IGivingService svc) => _svc = svc;
|
public GivingsController(IGivingService svc) => _svc = svc;
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[HasPermission(Modules.Givings, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetPaged(
|
public async Task<IActionResult> GetPaged(
|
||||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
||||||
[FromQuery] string? search = null, [FromQuery] int? categoryId = null,
|
[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));
|
=> Ok(await _svc.GetPagedAsync(page, pageSize, search, categoryId, from, to));
|
||||||
|
|
||||||
[HttpGet("{id:int}")]
|
[HttpGet("{id:int}")]
|
||||||
|
[HasPermission(Modules.Givings, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetById(int id)
|
public async Task<IActionResult> GetById(int id)
|
||||||
{
|
{
|
||||||
var dto = await _svc.GetByIdAsync(id);
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
@@ -28,6 +31,7 @@ public class GivingsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.Givings, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateGivingRequest request)
|
public async Task<IActionResult> Create([FromBody] CreateGivingRequest request)
|
||||||
{
|
{
|
||||||
var id = await _svc.CreateAsync(request);
|
var id = await _svc.CreateAsync(request);
|
||||||
@@ -35,6 +39,7 @@ public class GivingsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
|
[HasPermission(Modules.Givings, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingRequest request)
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingRequest request)
|
||||||
{
|
{
|
||||||
try { await _svc.UpdateAsync(id, request); return NoContent(); }
|
try { await _svc.UpdateAsync(id, request); return NoContent(); }
|
||||||
@@ -43,6 +48,7 @@ public class GivingsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
|
[HasPermission(Modules.Givings, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> Delete(int id)
|
public async Task<IActionResult> Delete(int id)
|
||||||
{
|
{
|
||||||
try { await _svc.DeleteAsync(id); return NoContent(); }
|
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")]
|
[HttpGet("today")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetToday()
|
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>
|
/// <summary>Daily counts within a date range, for the back-office dashboard chart.</summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Members;
|
using ROLAC.API.DTOs.Members;
|
||||||
using ROLAC.API.Services;
|
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>
|
/// <summary>GET /api/members?page=1&pageSize=20&search=Chen&status=Member&hasUser=false</summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Roles = "super_admin,secretary,pastor")]
|
[HasPermission(Modules.Members, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetPaged(
|
public async Task<IActionResult> GetPaged(
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
[FromQuery] int pageSize = 20,
|
[FromQuery] int pageSize = 20,
|
||||||
@@ -26,7 +27,7 @@ public class MembersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>GET /api/members/{id}</summary>
|
/// <summary>GET /api/members/{id}</summary>
|
||||||
[HttpGet("{id:int}")]
|
[HttpGet("{id:int}")]
|
||||||
[Authorize(Roles = "super_admin,secretary,pastor")]
|
[HasPermission(Modules.Members, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetById(int id)
|
public async Task<IActionResult> GetById(int id)
|
||||||
{
|
{
|
||||||
var dto = await _members.GetByIdAsync(id);
|
var dto = await _members.GetByIdAsync(id);
|
||||||
@@ -35,7 +36,7 @@ public class MembersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>POST /api/members</summary>
|
/// <summary>POST /api/members</summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Roles = "super_admin,secretary")]
|
[HasPermission(Modules.Members, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateMemberRequest request)
|
public async Task<IActionResult> Create([FromBody] CreateMemberRequest request)
|
||||||
{
|
{
|
||||||
var id = await _members.CreateAsync(request);
|
var id = await _members.CreateAsync(request);
|
||||||
@@ -44,7 +45,7 @@ public class MembersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>PUT /api/members/{id}</summary>
|
/// <summary>PUT /api/members/{id}</summary>
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
[Authorize(Roles = "super_admin,secretary")]
|
[HasPermission(Modules.Members, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateMemberRequest request)
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateMemberRequest request)
|
||||||
{
|
{
|
||||||
try { await _members.UpdateAsync(id, request); return NoContent(); }
|
try { await _members.UpdateAsync(id, request); return NoContent(); }
|
||||||
@@ -53,7 +54,7 @@ public class MembersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>DELETE /api/members/{id} — soft delete</summary>
|
/// <summary>DELETE /api/members/{id} — soft delete</summary>
|
||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
[Authorize(Roles = "super_admin,secretary")]
|
[HasPermission(Modules.Members, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> Delete(int id)
|
public async Task<IActionResult> Delete(int id)
|
||||||
{
|
{
|
||||||
try { await _members.DeleteAsync(id); return NoContent(); }
|
try { await _members.DeleteAsync(id); return NoContent(); }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Expense;
|
using ROLAC.API.DTOs.Expense;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -7,17 +8,19 @@ namespace ROLAC.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/monthly-statements")]
|
[Route("api/monthly-statements")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[Authorize]
|
||||||
public class MonthlyStatementsController : ControllerBase
|
public class MonthlyStatementsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IMonthlyStatementService _svc;
|
private readonly IMonthlyStatementService _svc;
|
||||||
public MonthlyStatementsController(IMonthlyStatementService svc) => _svc = svc;
|
public MonthlyStatementsController(IMonthlyStatementService svc) => _svc = svc;
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[HasPermission(Modules.MonthlyStatements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetAll([FromQuery] int? year = null)
|
public async Task<IActionResult> GetAll([FromQuery] int? year = null)
|
||||||
=> Ok(await _svc.GetAllAsync(year));
|
=> Ok(await _svc.GetAllAsync(year));
|
||||||
|
|
||||||
[HttpGet("{id:int}")]
|
[HttpGet("{id:int}")]
|
||||||
|
[HasPermission(Modules.MonthlyStatements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetById(int id)
|
public async Task<IActionResult> GetById(int id)
|
||||||
{
|
{
|
||||||
var dto = await _svc.GetByIdAsync(id);
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
@@ -25,6 +28,7 @@ public class MonthlyStatementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.MonthlyStatements, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateMonthlyStatementRequest r)
|
public async Task<IActionResult> Create([FromBody] CreateMonthlyStatementRequest r)
|
||||||
{
|
{
|
||||||
try { return Ok(new { id = await _svc.CreateAsync(r) }); }
|
try { return Ok(new { id = await _svc.CreateAsync(r) }); }
|
||||||
@@ -32,6 +36,7 @@ public class MonthlyStatementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
|
[HasPermission(Modules.MonthlyStatements, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateMonthlyStatementRequest r)
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateMonthlyStatementRequest r)
|
||||||
{
|
{
|
||||||
try { await _svc.UpdateAsync(id, r); return NoContent(); }
|
try { await _svc.UpdateAsync(id, r); return NoContent(); }
|
||||||
@@ -40,6 +45,7 @@ public class MonthlyStatementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:int}/finalize")]
|
[HttpPost("{id:int}/finalize")]
|
||||||
|
[HasPermission(Modules.MonthlyStatements, PermissionActions.Approve)]
|
||||||
public async Task<IActionResult> Finalize(int id)
|
public async Task<IActionResult> Finalize(int id)
|
||||||
{
|
{
|
||||||
try { await _svc.FinalizeAsync(id); return NoContent(); }
|
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.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Giving;
|
using ROLAC.API.DTOs.Giving;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -7,23 +8,26 @@ namespace ROLAC.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/offering-sessions")]
|
[Route("api/offering-sessions")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[Authorize]
|
||||||
public class OfferingSessionsController : ControllerBase
|
public class OfferingSessionsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IOfferingSessionService _svc;
|
private readonly IOfferingSessionService _svc;
|
||||||
public OfferingSessionsController(IOfferingSessionService svc) => _svc = svc;
|
public OfferingSessionsController(IOfferingSessionService svc) => _svc = svc;
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetPaged(
|
public async Task<IActionResult> GetPaged(
|
||||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
||||||
[FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
[FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
||||||
=> Ok(await _svc.GetPagedAsync(page, pageSize, from, to));
|
=> Ok(await _svc.GetPagedAsync(page, pageSize, from, to));
|
||||||
|
|
||||||
[HttpGet("check-date")]
|
[HttpGet("check-date")]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> CheckDate([FromQuery] DateOnly date)
|
public async Task<IActionResult> CheckDate([FromQuery] DateOnly date)
|
||||||
=> Ok(new { exists = await _svc.DateExistsAsync(date) });
|
=> Ok(new { exists = await _svc.DateExistsAsync(date) });
|
||||||
|
|
||||||
[HttpGet("{id:int}")]
|
[HttpGet("{id:int}")]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetById(int id)
|
public async Task<IActionResult> GetById(int id)
|
||||||
{
|
{
|
||||||
var dto = await _svc.GetByIdAsync(id);
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
@@ -31,6 +35,7 @@ public class OfferingSessionsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateOfferingSessionRequest request)
|
public async Task<IActionResult> Create([FromBody] CreateOfferingSessionRequest request)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -42,6 +47,7 @@ public class OfferingSessionsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:int}/reopen")]
|
[HttpPost("{id:int}/reopen")]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Approve)]
|
||||||
public async Task<IActionResult> Reopen(int id)
|
public async Task<IActionResult> Reopen(int id)
|
||||||
{
|
{
|
||||||
try { await _svc.ReopenAsync(id); return NoContent(); }
|
try { await _svc.ReopenAsync(id); return NoContent(); }
|
||||||
@@ -50,6 +56,7 @@ public class OfferingSessionsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Replace(int id, [FromBody] CreateOfferingSessionRequest request)
|
public async Task<IActionResult> Replace(int id, [FromBody] CreateOfferingSessionRequest request)
|
||||||
{
|
{
|
||||||
try { await _svc.ReplaceAsync(id, request); return NoContent(); }
|
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) ───────────
|
// ── Paper-proof PDF (merged client-side, one file per session) ───────────
|
||||||
|
|
||||||
[HttpPost("{id:int}/proof")]
|
[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
|
[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)
|
public async Task<IActionResult> UploadProof(int id, IFormFile file)
|
||||||
{
|
{
|
||||||
@@ -75,6 +83,7 @@ public class OfferingSessionsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:int}/proof")]
|
[HttpGet("{id:int}/proof")]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetProof(int id)
|
public async Task<IActionResult> GetProof(int id)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -87,6 +96,7 @@ public class OfferingSessionsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:int}/proof")]
|
[HttpDelete("{id:int}/proof")]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> DeleteProof(int id)
|
public async Task<IActionResult> DeleteProof(int id)
|
||||||
{
|
{
|
||||||
try { await _svc.DeleteProofAsync(id); return NoContent(); }
|
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>());
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Users;
|
using ROLAC.API.DTOs.Users;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -7,7 +8,7 @@ namespace ROLAC.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/users")]
|
[Route("api/users")]
|
||||||
[Authorize(Roles = "super_admin")]
|
[Authorize]
|
||||||
public class UsersController : ControllerBase
|
public class UsersController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IUserManagementService _users;
|
private readonly IUserManagementService _users;
|
||||||
@@ -15,6 +16,7 @@ public class UsersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>GET /api/users?page=1&pageSize=20&search=Chris</summary>
|
/// <summary>GET /api/users?page=1&pageSize=20&search=Chris</summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetPaged(
|
public async Task<IActionResult> GetPaged(
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
[FromQuery] int pageSize = 20,
|
[FromQuery] int pageSize = 20,
|
||||||
@@ -23,6 +25,7 @@ public class UsersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>GET /api/users/{id}</summary>
|
/// <summary>GET /api/users/{id}</summary>
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetById(string id)
|
public async Task<IActionResult> GetById(string id)
|
||||||
{
|
{
|
||||||
var dto = await _users.GetByIdAsync(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.
|
/// TempPassword is returned ONCE — show it to the admin and never log it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateUserRequest request)
|
public async Task<IActionResult> Create([FromBody] CreateUserRequest request)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -49,6 +53,7 @@ public class UsersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>PUT /api/users/{id} — update email, roles, IsActive</summary>
|
/// <summary>PUT /api/users/{id} — update email, roles, IsActive</summary>
|
||||||
[HttpPut("{id}")]
|
[HttpPut("{id}")]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Update(string id, [FromBody] UpdateUserRequest request)
|
public async Task<IActionResult> Update(string id, [FromBody] UpdateUserRequest request)
|
||||||
{
|
{
|
||||||
try { await _users.UpdateAsync(id, request); return NoContent(); }
|
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>
|
/// <summary>DELETE /api/users/{id} — deactivates account (IsActive=false), does not delete</summary>
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> Deactivate(string id)
|
public async Task<IActionResult> Deactivate(string id)
|
||||||
{
|
{
|
||||||
try { await _users.DeactivateAsync(id); return NoContent(); }
|
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>
|
/// <summary>POST /api/users/{id}/reset-password — returns new temp password</summary>
|
||||||
[HttpPost("{id}/reset-password")]
|
[HttpPost("{id}/reset-password")]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> ResetPassword(string id)
|
public async Task<IActionResult> ResetPassword(string id)
|
||||||
{
|
{
|
||||||
try
|
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!;
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ROLAC.API.DTOs.Permissions;
|
||||||
|
|
||||||
namespace ROLAC.API.DTOs.Auth;
|
namespace ROLAC.API.DTOs.Auth;
|
||||||
|
|
||||||
public class LoginResponse
|
public class LoginResponse
|
||||||
@@ -17,4 +19,10 @@ public class UserInfo
|
|||||||
public string Email { get; set; } = null!;
|
public string Email { get; set; } = null!;
|
||||||
public IList<string> Roles { get; set; } = [];
|
public IList<string> Roles { get; set; } = [];
|
||||||
public string LanguagePreference { get; set; } = "en";
|
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; } = [];
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data.Logging;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Entities.Notifications;
|
||||||
|
|
||||||
namespace ROLAC.API.Data;
|
namespace ROLAC.API.Data;
|
||||||
|
|
||||||
@@ -23,6 +25,12 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
public DbSet<Check> Checks => Set<Check>();
|
public DbSet<Check> Checks => Set<Check>();
|
||||||
public DbSet<CheckLine> CheckLines => Set<CheckLine>();
|
public DbSet<CheckLine> CheckLines => Set<CheckLine>();
|
||||||
public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>();
|
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)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
@@ -60,6 +68,18 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Property(e => e.Description).HasMaxLength(500);
|
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 ──────────────────────────────────────────────────────
|
// ── FamilyUnit ──────────────────────────────────────────────────────
|
||||||
builder.Entity<FamilyUnit>(entity =>
|
builder.Entity<FamilyUnit>(entity =>
|
||||||
{
|
{
|
||||||
@@ -311,5 +331,55 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
entity.HasIndex(e => new { e.Year, e.Month }).IsUnique();
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
|
|
||||||
namespace ROLAC.API.Data;
|
namespace ROLAC.API.Data;
|
||||||
@@ -62,6 +63,67 @@ public static class DbSeeder
|
|||||||
("visitor", "Visitor — public pages only"),
|
("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)
|
public static async Task SeedRolesAsync(RoleManager<AppRole> roleManager)
|
||||||
{
|
{
|
||||||
foreach (var (name, description) in Roles)
|
foreach (var (name, description) in Roles)
|
||||||
@@ -159,6 +221,7 @@ public static class DbSeeder
|
|||||||
await SeedRolesAsync(roleManager);
|
await SeedRolesAsync(roleManager);
|
||||||
|
|
||||||
var db = services.GetRequiredService<AppDbContext>();
|
var db = services.GetRequiredService<AppDbContext>();
|
||||||
|
await SeedRolePermissionsAsync(db);
|
||||||
await SeedGivingCategoriesAsync(db);
|
await SeedGivingCategoriesAsync(db);
|
||||||
await SeedMinistriesAsync(db);
|
await SeedMinistriesAsync(db);
|
||||||
await SeedExpenseCategoriesAsync(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;
|
||||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
using ROLAC.API.Entities.Base;
|
using ROLAC.API.Entities.Base;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
|
||||||
namespace ROLAC.API.Data.Interceptors;
|
namespace ROLAC.API.Data.Interceptors;
|
||||||
|
|
||||||
public class AuditSaveChangesInterceptor : SaveChangesInterceptor
|
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(
|
public override InterceptionResult<int> SavingChanges(
|
||||||
DbContextEventData eventData, InterceptionResult<int> result)
|
DbContextEventData eventData, InterceptionResult<int> result)
|
||||||
@@ -30,8 +30,7 @@ public class AuditSaveChangesInterceptor : SaveChangesInterceptor
|
|||||||
{
|
{
|
||||||
if (db is null) return;
|
if (db is null) return;
|
||||||
|
|
||||||
var userId = _http.HttpContext?.User
|
var userId = _currentUser.UserIdOrSystem;
|
||||||
.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
foreach (var entry in db.ChangeTracker.Entries())
|
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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ namespace ROLAC.API.Entities;
|
|||||||
/// expenses (its <see cref="Lines"/>). The payee name/address are snapshotted at
|
/// 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.
|
/// issue time so the printed check is reproducible even if member data later changes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Check : SoftDeleteEntity
|
public class Check : SoftDeleteEntity, IAuditable
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string CheckNumber { get; set; } = null!;
|
public string CheckNumber { get; set; } = null!;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace ROLAC.API.Entities;
|
|||||||
/// Singleton (Id == 1) holding the issuing church's identity, bank details, and the
|
/// Singleton (Id == 1) holding the issuing church's identity, bank details, and the
|
||||||
/// running check-number counter used when disbursing checks. Seeded on startup.
|
/// running check-number counter used when disbursing checks. Seeded on startup.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ChurchProfile : AuditableEntity
|
public class ChurchProfile : AuditableEntity, IAuditable
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Name { get; set; } = null!;
|
public string Name { get; set; } = null!;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using ROLAC.API.Entities.Base;
|
using ROLAC.API.Entities.Base;
|
||||||
namespace ROLAC.API.Entities;
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
public class Expense : SoftDeleteEntity
|
public class Expense : SoftDeleteEntity, IAuditable
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int MinistryId { get; set; }
|
public int MinistryId { get; set; }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using ROLAC.API.Entities.Base;
|
using ROLAC.API.Entities.Base;
|
||||||
namespace ROLAC.API.Entities;
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
public class ExpenseCategoryGroup : AuditableEntity
|
public class ExpenseCategoryGroup : AuditableEntity, IAuditable
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Name_en { get; set; } = null!;
|
public string Name_en { get; set; } = null!;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using ROLAC.API.Entities.Base;
|
using ROLAC.API.Entities.Base;
|
||||||
namespace ROLAC.API.Entities;
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
public class ExpenseSubCategory : AuditableEntity
|
public class ExpenseSubCategory : AuditableEntity, IAuditable
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int GroupId { get; set; }
|
public int GroupId { get; set; }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ using ROLAC.API.Entities.Base;
|
|||||||
|
|
||||||
namespace ROLAC.API.Entities;
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
public class Giving : AuditableEntity
|
public class Giving : AuditableEntity, IAuditable
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int? MemberId { get; set; }
|
public int? MemberId { get; set; }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ using ROLAC.API.Entities.Base;
|
|||||||
|
|
||||||
namespace ROLAC.API.Entities;
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
public class GivingCategory : AuditableEntity
|
public class GivingCategory : AuditableEntity, IAuditable
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Name_en { get; set; } = null!;
|
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; }
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ using ROLAC.API.Entities.Base;
|
|||||||
|
|
||||||
namespace ROLAC.API.Entities;
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
public class Member : SoftDeleteEntity
|
public class Member : SoftDeleteEntity, IAuditable
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string FirstName_en { get; set; } = null!;
|
public string FirstName_en { get; set; } = null!;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
|
||||||
namespace ROLAC.API.Entities;
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
public class Ministry
|
public class Ministry : IAuditable
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Name_en { get; set; } = null!;
|
public string Name_en { get; set; } = null!;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using ROLAC.API.Entities.Base;
|
using ROLAC.API.Entities.Base;
|
||||||
namespace ROLAC.API.Entities;
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
public class MonthlyStatement : AuditableEntity
|
public class MonthlyStatement : AuditableEntity, IAuditable
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int Year { 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; }
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ using ROLAC.API.Entities.Base;
|
|||||||
|
|
||||||
namespace ROLAC.API.Entities;
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
public class OfferingSession : AuditableEntity
|
public class OfferingSession : AuditableEntity, IAuditable
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public DateOnly SessionDate { get; set; }
|
public DateOnly SessionDate { get; set; }
|
||||||
|
|||||||
@@ -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!;
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ public class AttendanceHub : Hub
|
|||||||
// Push the current counts to a client the moment it connects.
|
// Push the current counts to a client the moment it connects.
|
||||||
public override async Task OnConnectedAsync()
|
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 Clients.Caller.SendAsync("ReceiveCounts", counts);
|
||||||
await base.OnConnectedAsync();
|
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.
|
// Apply a batched delta for one age group, then broadcast the new totals to everyone.
|
||||||
public async Task Increment(string category, int delta)
|
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);
|
await Clients.All.SendAsync("ReceiveCounts", counts);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overwrite one age group with an absolute value, then broadcast the new totals to everyone.
|
// Overwrite one age group with an absolute value, then broadcast the new totals to everyone.
|
||||||
public async Task SetCount(string category, int value)
|
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);
|
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");
|
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 =>
|
modelBuilder.Entity("ROLAC.API.Entities.MealAttendance", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -1186,6 +1323,174 @@ namespace ROLAC.API.Migrations
|
|||||||
b.ToTable("MonthlyStatements");
|
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 =>
|
modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -1310,6 +1615,44 @@ namespace ROLAC.API.Migrations
|
|||||||
b.ToTable("RefreshTokens");
|
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 =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ROLAC.API.Entities.AppRole", null)
|
b.HasOne("ROLAC.API.Entities.AppRole", null)
|
||||||
@@ -1470,6 +1813,45 @@ namespace ROLAC.API.Migrations
|
|||||||
b.Navigation("FamilyUnit");
|
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 =>
|
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ROLAC.API.Entities.AppUser", "User")
|
b.HasOne("ROLAC.API.Entities.AppUser", "User")
|
||||||
@@ -1481,6 +1863,17 @@ namespace ROLAC.API.Migrations
|
|||||||
b.Navigation("User");
|
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 =>
|
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("RefreshTokens");
|
b.Navigation("RefreshTokens");
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ROLAC.API.Data;
|
using ROLAC.API.Data;
|
||||||
using ROLAC.API.Data.Interceptors;
|
using ROLAC.API.Data.Interceptors;
|
||||||
|
using ROLAC.API.Data.Logging;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
using ROLAC.API.Json;
|
using ROLAC.API.Json;
|
||||||
|
using ROLAC.API.Middleware;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
var config = builder.Configuration;
|
var config = builder.Configuration;
|
||||||
@@ -19,10 +23,31 @@ var config = builder.Configuration;
|
|||||||
// Database
|
// Database
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
builder.Services.AddScoped<CurrentUserAccessor>();
|
||||||
builder.Services.AddScoped<AuditSaveChangesInterceptor>();
|
builder.Services.AddScoped<AuditSaveChangesInterceptor>();
|
||||||
|
builder.Services.AddScoped<AuditLogInterceptor>();
|
||||||
builder.Services.AddDbContext<AppDbContext>((sp, opt) =>
|
builder.Services.AddDbContext<AppDbContext>((sp, opt) =>
|
||||||
opt.UseNpgsql(config.GetConnectionString("DefaultConnection"))
|
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)
|
// 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>();
|
ROLAC.API.Services.Disbursement.CheckPrintService>();
|
||||||
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
|
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.
|
// Real-time hub for the live Sunday attendance counter.
|
||||||
builder.Services.AddSignalR();
|
builder.Services.AddSignalR();
|
||||||
|
|
||||||
@@ -187,6 +237,10 @@ app.UseForwardedHeaders(new ForwardedHeadersOptions
|
|||||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
|
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
|
// Apply migrations + seed on startup
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
Provides DevExpress.Drawing.v24.1.Skia.dll; without it RichEditDocumentServer
|
Provides DevExpress.Drawing.v24.1.Skia.dll; without it RichEditDocumentServer
|
||||||
throws DllNotFoundException at runtime on Linux (Windows falls back to GDI+). -->
|
throws DllNotFoundException at runtime on Linux (Windows falls back to GDI+). -->
|
||||||
<PackageReference Include="DevExpress.Drawing.Skia" Version="24.1.3" />
|
<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.Authentication.JwtBearer" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ using Microsoft.Extensions.Configuration;
|
|||||||
using ROLAC.API.Data;
|
using ROLAC.API.Data;
|
||||||
using ROLAC.API.DTOs.Auth;
|
using ROLAC.API.DTOs.Auth;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Entities.Logging;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
|
||||||
namespace ROLAC.API.Services;
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -12,17 +14,23 @@ public class AuthService : IAuthService
|
|||||||
private readonly UserManager<AppUser> _userManager;
|
private readonly UserManager<AppUser> _userManager;
|
||||||
private readonly ITokenService _tokenService;
|
private readonly ITokenService _tokenService;
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IPermissionService _permissions;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
private readonly int _refreshTokenExpiryDays;
|
private readonly int _refreshTokenExpiryDays;
|
||||||
|
|
||||||
public AuthService(
|
public AuthService(
|
||||||
UserManager<AppUser> userManager,
|
UserManager<AppUser> userManager,
|
||||||
ITokenService tokenService,
|
ITokenService tokenService,
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
|
IPermissionService permissions,
|
||||||
|
IAuditLogger audit,
|
||||||
IConfiguration config)
|
IConfiguration config)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_tokenService = tokenService;
|
_tokenService = tokenService;
|
||||||
_db = db;
|
_db = db;
|
||||||
|
_permissions = permissions;
|
||||||
|
_audit = audit;
|
||||||
_refreshTokenExpiryDays = int.Parse(config["Jwt:RefreshTokenExpiryDays"] ?? "30");
|
_refreshTokenExpiryDays = int.Parse(config["Jwt:RefreshTokenExpiryDays"] ?? "30");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,13 +43,22 @@ public class AuthService : IAuthService
|
|||||||
{
|
{
|
||||||
var user = await _userManager.FindByEmailAsync(request.Email);
|
var user = await _userManager.FindByEmailAsync(request.Email);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
|
{
|
||||||
|
AuditLoginFailed(request.Email, "Unknown email", ipAddress);
|
||||||
throw new UnauthorizedAccessException("Invalid credentials.");
|
throw new UnauthorizedAccessException("Invalid credentials.");
|
||||||
|
}
|
||||||
|
|
||||||
if (!await _userManager.CheckPasswordAsync(user, request.Password))
|
if (!await _userManager.CheckPasswordAsync(user, request.Password))
|
||||||
|
{
|
||||||
|
AuditLoginFailed(request.Email, "Wrong password", ipAddress, user.Id);
|
||||||
throw new UnauthorizedAccessException("Invalid credentials.");
|
throw new UnauthorizedAccessException("Invalid credentials.");
|
||||||
|
}
|
||||||
|
|
||||||
if (!user.IsActive)
|
if (!user.IsActive)
|
||||||
|
{
|
||||||
|
AuditLoginFailed(request.Email, "Account inactive", ipAddress, user.Id);
|
||||||
throw new UnauthorizedAccessException("Account is inactive.");
|
throw new UnauthorizedAccessException("Account is inactive.");
|
||||||
|
}
|
||||||
|
|
||||||
var roles = await _userManager.GetRolesAsync(user);
|
var roles = await _userManager.GetRolesAsync(user);
|
||||||
var accessToken = _tokenService.GenerateAccessToken(user, roles);
|
var accessToken = _tokenService.GenerateAccessToken(user, roles);
|
||||||
@@ -62,9 +79,22 @@ public class AuthService : IAuthService
|
|||||||
await _userManager.UpdateAsync(user);
|
await _userManager.UpdateAsync(user);
|
||||||
await _db.SaveChangesAsync();
|
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
|
// Refresh
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -104,7 +134,7 @@ public class AuthService : IAuthService
|
|||||||
|
|
||||||
await _db.SaveChangesAsync();
|
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;
|
token.RevokedAt = DateTime.UtcNow;
|
||||||
await _db.SaveChangesAsync();
|
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 helpers
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
private static LoginResponse BuildResponse(
|
private async Task<LoginResponse> BuildResponseAsync(
|
||||||
string accessToken, AppUser user, IList<string> roles)
|
string accessToken, AppUser user, IList<string> roles)
|
||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
AccessToken = accessToken,
|
AccessToken = accessToken,
|
||||||
ExpiresIn = 15 * 60,
|
ExpiresIn = 15 * 60,
|
||||||
User = new UserInfo
|
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,
|
Id = user.Id,
|
||||||
Email = user.Email!,
|
Email = user.Email!,
|
||||||
Roles = roles,
|
Roles = roles,
|
||||||
LanguagePreference = user.LanguagePreference,
|
LanguagePreference = user.LanguagePreference,
|
||||||
},
|
Permissions = await _permissions.GetEffectivePermissionsAsync(roles),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ using ROLAC.API.Data;
|
|||||||
using ROLAC.API.DTOs.Disbursement;
|
using ROLAC.API.DTOs.Disbursement;
|
||||||
using ROLAC.API.DTOs.Shared;
|
using ROLAC.API.DTOs.Shared;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Entities.Logging;
|
||||||
using ROLAC.API.Services.Disbursement;
|
using ROLAC.API.Services.Disbursement;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
using ROLAC.API.Services.Storage;
|
using ROLAC.API.Services.Storage;
|
||||||
|
|
||||||
namespace ROLAC.API.Services;
|
namespace ROLAC.API.Services;
|
||||||
@@ -15,10 +17,11 @@ public class DisbursementService : IDisbursementService
|
|||||||
private readonly IHttpContextAccessor _http;
|
private readonly IHttpContextAccessor _http;
|
||||||
private readonly IFileStorage _storage;
|
private readonly IFileStorage _storage;
|
||||||
private readonly ICheckPrintService _print;
|
private readonly ICheckPrintService _print;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
|
||||||
public DisbursementService(AppDbContext db, IHttpContextAccessor http,
|
public DisbursementService(AppDbContext db, IHttpContextAccessor http,
|
||||||
IFileStorage storage, ICheckPrintService print)
|
IFileStorage storage, ICheckPrintService print, IAuditLogger audit)
|
||||||
{ _db = db; _http = http; _storage = storage; _print = print; }
|
{ _db = db; _http = http; _storage = storage; _print = print; _audit = audit; }
|
||||||
|
|
||||||
// The JWT carries the user id in the "sub" claim (NameClaimType="sub"); NameIdentifier
|
// 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).
|
// 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
|
result.Created.Add(new IssuedCheckDto
|
||||||
{ CheckId = check.Id, CheckNumber = checkNumber, PayeeName = p.PayeeName, Amount = amount });
|
{ 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();
|
await tx.CommitAsync();
|
||||||
@@ -227,6 +235,11 @@ public class DisbursementService : IDisbursementService
|
|||||||
}
|
}
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
await tx.CommitAsync();
|
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 ─────────────────────────────────────────────────────
|
// ── Receipt e-signature ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ using ROLAC.API.Data;
|
|||||||
using ROLAC.API.DTOs.Expense;
|
using ROLAC.API.DTOs.Expense;
|
||||||
using ROLAC.API.DTOs.Shared;
|
using ROLAC.API.DTOs.Shared;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Entities.Logging;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
using ROLAC.API.Services.Storage;
|
using ROLAC.API.Services.Storage;
|
||||||
|
|
||||||
namespace ROLAC.API.Services;
|
namespace ROLAC.API.Services;
|
||||||
@@ -13,9 +15,10 @@ public class ExpenseService : IExpenseService
|
|||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly IHttpContextAccessor _http;
|
private readonly IHttpContextAccessor _http;
|
||||||
private readonly IFileStorage _storage;
|
private readonly IFileStorage _storage;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
|
||||||
public ExpenseService(AppDbContext db, IHttpContextAccessor http, IFileStorage storage)
|
public ExpenseService(AppDbContext db, IHttpContextAccessor http, IFileStorage storage, IAuditLogger audit)
|
||||||
{ _db = db; _http = http; _storage = storage; }
|
{ _db = db; _http = http; _storage = storage; _audit = audit; }
|
||||||
|
|
||||||
// The JWT carries the user id in the "sub" claim (NameClaimType="sub", MapInboundClaims=false),
|
// 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),
|
// 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.
|
// FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies.
|
||||||
var e = await _db.Expenses.FirstOrDefaultAsync(x => x.Id == id)
|
var e = await _db.Expenses.FirstOrDefaultAsync(x => x.Id == id)
|
||||||
?? throw new KeyNotFoundException($"Expense {id} not found.");
|
?? throw new KeyNotFoundException($"Expense {id} not found.");
|
||||||
if (!isFinance && !(e.SubmittedBy == CurrentUserId && e.Status == "Draft"))
|
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval")))
|
||||||
throw new InvalidOperationException("You can only edit your own draft reimbursements.");
|
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.MinistryId = r.MinistryId; e.CategoryGroupId = r.CategoryGroupId; e.SubCategoryId = r.SubCategoryId;
|
||||||
e.Amount = r.Amount; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
|
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}'.");
|
if (e.Status != "PendingApproval") throw new InvalidOperationException($"Cannot approve from status '{e.Status}'.");
|
||||||
e.Status = "Approved"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow;
|
e.Status = "Approved"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow;
|
||||||
await _db.SaveChangesAsync();
|
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)
|
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)
|
public async Task SaveReceiptAsync(int id, Stream content, string fileName, bool isFinance)
|
||||||
{
|
{
|
||||||
var e = await RequireAsync(id);
|
var e = await RequireAsync(id);
|
||||||
if (!isFinance && e.SubmittedBy != CurrentUserId)
|
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval")))
|
||||||
throw new InvalidOperationException("You can only attach receipts to your own reimbursements.");
|
throw new InvalidOperationException("You can only attach receipts to your own draft or pending reimbursements.");
|
||||||
|
|
||||||
var safe = Path.GetFileName(fileName).Replace(' ', '_');
|
var safe = Path.GetFileName(fileName).Replace(' ', '_');
|
||||||
var path = $"finance/receipts/{e.ExpenseDate.Year}/{e.ExpenseDate.Month}/{e.Id}-{safe}";
|
var path = $"finance/receipts/{e.ExpenseDate.Year}/{e.ExpenseDate.Month}/{e.Id}-{safe}";
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using ROLAC.API.DTOs.Auth;
|
using ROLAC.API.DTOs.Auth;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
|
||||||
namespace ROLAC.API.Services;
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -28,4 +30,25 @@ public interface IAuthService
|
|||||||
/// Silently succeeds if the token is not found.
|
/// Silently succeeds if the token is not found.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task LogoutAsync(string rawRefreshToken);
|
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
|
public interface IMealAttendanceService
|
||||||
{
|
{
|
||||||
/// <summary>Today's date in the server's local time zone (the church's "current Sunday").</summary>
|
/// <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>
|
/// <summary>Returns the counts for <paramref name="date"/>, creating a zeroed row if none exists.</summary>
|
||||||
Task<AttendanceCountsDto> GetOrCreateAsync(DateOnly date);
|
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;
|
public MealAttendanceService(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
// Server local time is assumed to match the church's local day.
|
// 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)
|
public async Task<AttendanceCountsDto> GetOrCreateAsync(DateOnly date)
|
||||||
{
|
{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user