Compare commits
154 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 | |||
| deff2264a6 | |||
| 2b28d2079c | |||
| c7ac431deb | |||
| 8a159f1b79 | |||
| 70ea56280c | |||
| bcd6b39356 | |||
| 1fb97cfccc | |||
| 0924b1a980 | |||
| 807e88f929 | |||
| b8a4c9b727 | |||
| d2dc568794 | |||
| a537974edf | |||
| ef3731ba48 | |||
| ddced87dc6 | |||
| 7ab8e9703b | |||
| aaaae09bd2 | |||
| 8061a60fe5 | |||
| 87425b3276 | |||
| 2af169fa60 | |||
| 3558c67fd7 | |||
| f55807fa7d | |||
| b6c50a38aa | |||
| caed5091f0 | |||
| 769597d769 | |||
| 241870fe48 | |||
| e817801e14 | |||
| aef5454202 | |||
| 9a94e3b09e | |||
| fba0b63214 | |||
| e5296e79dc | |||
| 61e34d343a | |||
| 126e640731 | |||
| 4e15e9f630 | |||
| 4bee06addb | |||
| a99755a5db | |||
| fef3b76a31 | |||
| e37aade69f | |||
| fe50ea3d30 | |||
| 95fa37ebdf | |||
| e1f99158aa | |||
| 95008788f3 | |||
| f5ff03260b | |||
| aa77f2051a | |||
| 4704d33b4a | |||
| 18b9707e44 | |||
| 1bb9da16d4 | |||
| 3188064335 | |||
| 04b05617b8 | |||
| 9933c180b7 | |||
| 86d9879a6d | |||
| d9289008f6 | |||
| 015f689d9b | |||
| 15cdfe6f92 | |||
| e7bf07c2ad | |||
| ac65c68e18 | |||
| cf929557fe | |||
| cc58d06723 | |||
| b3eb9d297a | |||
| f6f06d841c | |||
| 50e518095e | |||
| fdd0d7c8e1 | |||
| 0639d1fe83 | |||
| a2d394029a | |||
| 5f8676f962 | |||
| 48885dba83 | |||
| af21e50d9f | |||
| a573179714 | |||
| 66640d1fd0 | |||
| 001db35cef | |||
| 81a0b5a038 | |||
| 7260e5c115 | |||
| 91247a7c69 | |||
| 4a2b142061 | |||
| b5a15dd9f2 | |||
| 86041c0d05 | |||
| e04776460d | |||
| 586551aec0 | |||
| 8ff93e3698 | |||
| 2b6f29e775 | |||
| 81efaedbc2 | |||
| cb15d30980 | |||
| 798dfa3fe0 | |||
| 8b52572fad | |||
| 577ae1aabe | |||
| e20964ae0d | |||
| 999f8a80f9 | |||
| 3974cec967 | |||
| 82b9744024 | |||
| a525c71baa | |||
| d79b1faa8f | |||
| e83fa4c2e9 | |||
| bc67146d86 | |||
| a18d44bd0a | |||
| 6c3292861a | |||
| 3a5b5721e4 | |||
| 07e0270599 | |||
| 32e47e4566 | |||
| d2eac52a47 | |||
| 8249b3fe3e | |||
| 3ab0998793 | |||
| 0986233d9b | |||
| bfffdee2a8 | |||
| 97743f6974 | |||
| 34344cbf83 | |||
| cd5413125d | |||
| 820ca6981c | |||
| f703519838 | |||
| 5041873c2b | |||
| 61c6697c87 | |||
| 5d556b882d | |||
| adad5cb7e9 |
@@ -0,0 +1,85 @@
|
||||
name: ci-cd-vm
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# Everything lives on the same Ubuntu VM (Gitea, the registry, the build, and the
|
||||
# runtime share one Docker daemon), so a single job on the `ubuntu` runner does
|
||||
# test -> build -> push -> deploy. No cross-machine pull is needed; deploy reuses
|
||||
# the images just built in the local Docker.
|
||||
jobs:
|
||||
ci-cd:
|
||||
runs-on: ubuntu
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
REGISTRY: git.golife.love/chrischen
|
||||
DEPLOY_DIR: /home/chris/docker/rolac
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Test API
|
||||
run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release
|
||||
|
||||
- name: Registry login
|
||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.golife.love -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: Build images
|
||||
run: |
|
||||
docker build -t "$REGISTRY/rolac-api:latest" -t "$REGISTRY/rolac-api:${{ github.sha }}" ./API
|
||||
docker build \
|
||||
--build-arg KENDO_UI_LICENSE="${{ secrets.KENDO_UI_LICENSE }}" \
|
||||
-t "$REGISTRY/rolac-app:latest" -t "$REGISTRY/rolac-app:${{ github.sha }}" ./APP
|
||||
|
||||
- name: Push images
|
||||
run: |
|
||||
docker push --all-tags "$REGISTRY/rolac-api"
|
||||
docker push --all-tags "$REGISTRY/rolac-app"
|
||||
|
||||
- name: Sync compose + nginx to deploy dir
|
||||
run: |
|
||||
mkdir -p "$DEPLOY_DIR/nginx/conf.d" "$DEPLOY_DIR/data/api-storage"
|
||||
cp deploy/vm/docker-compose.yml "$DEPLOY_DIR/docker-compose.yml"
|
||||
cp deploy/vm/nginx/conf.d/rolac.conf "$DEPLOY_DIR/nginx/conf.d/rolac.conf"
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
cd "$DEPLOY_DIR"
|
||||
export TAG=${{ github.sha }}
|
||||
docker compose up -d
|
||||
sleep 5
|
||||
curl -fsS http://localhost:8080/api/health
|
||||
|
||||
# Always runs (success or failure) so the team gets a build result in Rocket.Chat.
|
||||
- name: Notify Rocket.Chat
|
||||
if: always()
|
||||
env:
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
REPO: ${{ github.repository }}
|
||||
REF: ${{ github.ref_name }}
|
||||
SHA: ${{ github.sha }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}
|
||||
WEBHOOK: ${{ secrets.ROCKETCHAT_WEBHOOK }}
|
||||
run: |
|
||||
if [ "$JOB_STATUS" = "success" ]; then
|
||||
STATUS_TEXT="✅ Build succeeded"
|
||||
COLOR="#2ecc71"
|
||||
else
|
||||
STATUS_TEXT="❌ Build failed"
|
||||
COLOR="#e74c3c"
|
||||
fi
|
||||
SHORT_SHA="${SHA:0:7}"
|
||||
curl -fsS -X POST -H 'Content-Type: application/json' --data @- "$WEBHOOK" <<JSON
|
||||
{
|
||||
"attachments": [
|
||||
{
|
||||
"title": "$REPO — $STATUS_TEXT",
|
||||
"title_link": "$COMMIT_URL",
|
||||
"color": "$COLOR",
|
||||
"text": "Branch *$REF* · commit $SHORT_SHA · by $ACTOR"
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
@@ -0,0 +1,92 @@
|
||||
name: ci-cd
|
||||
on:
|
||||
push:
|
||||
branches: [azure-deploy]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with: { dotnet-version: '8.0.x' }
|
||||
- run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release
|
||||
|
||||
build-push:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.golife.love
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./API
|
||||
push: true
|
||||
tags: |
|
||||
git.golife.love/chrischen/rolac-api:latest
|
||||
git.golife.love/chrischen/rolac-api:${{ github.sha }}
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./APP
|
||||
push: true
|
||||
tags: |
|
||||
git.golife.love/chrischen/rolac-app:latest
|
||||
git.golife.love/chrischen/rolac-app:${{ github.sha }}
|
||||
|
||||
deploy:
|
||||
needs: build-push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.VM_HOST }}
|
||||
username: ${{ secrets.VM_USER }}
|
||||
key: ${{ secrets.VM_SSH_KEY }}
|
||||
script: |
|
||||
cd /opt/rolac/deploy
|
||||
export TAG=${{ github.sha }}
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
curl -fsS https://manage.rolac.org/api/health
|
||||
|
||||
# Always runs (success or failure) so the team gets a build result in Rocket.Chat.
|
||||
# A failed or skipped upstream job (skipped means an earlier job failed) reports as failed.
|
||||
notify:
|
||||
needs: [test, build-push, deploy]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Notify Rocket.Chat
|
||||
env:
|
||||
BUILD_FAILED: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }}
|
||||
REPO: ${{ github.repository }}
|
||||
REF: ${{ github.ref_name }}
|
||||
SHA: ${{ github.sha }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}
|
||||
WEBHOOK: ${{ secrets.ROCKETCHAT_WEBHOOK }}
|
||||
run: |
|
||||
if [ "$BUILD_FAILED" = "true" ]; then
|
||||
STATUS_TEXT="❌ Build failed"
|
||||
COLOR="#e74c3c"
|
||||
else
|
||||
STATUS_TEXT="✅ Build succeeded"
|
||||
COLOR="#2ecc71"
|
||||
fi
|
||||
SHORT_SHA="${SHA:0:7}"
|
||||
curl -fsS -X POST -H 'Content-Type: application/json' --data @- "$WEBHOOK" <<JSON
|
||||
{
|
||||
"attachments": [
|
||||
{
|
||||
"title": "$REPO — $STATUS_TEXT",
|
||||
"title_link": "$COMMIT_URL",
|
||||
"color": "$COLOR",
|
||||
"text": "Branch *$REF* · commit $SHORT_SHA · by $ACTOR"
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
@@ -92,3 +92,5 @@ logs/
|
||||
*.tmp
|
||||
*.temp
|
||||
/.claude
|
||||
/API/ROLAC.API/bin-verify
|
||||
API/ROLAC.API/App_Data/
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
**/bin
|
||||
**/obj
|
||||
**/appsettings.Development.json
|
||||
**/appsettings.*.local.json
|
||||
ROLAC.API.Tests/
|
||||
.git
|
||||
*.user
|
||||
@@ -0,0 +1,35 @@
|
||||
# ---- build ----
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
WORKDIR /src
|
||||
# nuget.config carries the DevExpress licensed feed so restore resolves 24.1.3
|
||||
# (the public feed only offers the trial 25.x, which renamed TableBorderLineStyle).
|
||||
COPY nuget.config ./
|
||||
COPY ROLAC.API/ROLAC.API.csproj ROLAC.API/
|
||||
RUN dotnet restore ROLAC.API/ROLAC.API.csproj --configfile nuget.config
|
||||
COPY ROLAC.API/ ROLAC.API/
|
||||
RUN dotnet publish ROLAC.API/ROLAC.API.csproj -c Release -o /app/publish /p:UseAppHost=false --no-restore
|
||||
|
||||
# ---- runtime ----
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
|
||||
WORKDIR /app
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production \
|
||||
ASPNETCORE_HTTP_PORTS=8080
|
||||
# curl: used by the HEALTHCHECK (not present in the base image)
|
||||
# libfontconfig1 + fonts: required by DevExpress.Drawing's Skia backend for PDF text
|
||||
# rendering. fonts-noto-cjk supplies the Chinese glyphs used in the receipt PDF.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
libfontconfig1 \
|
||||
fontconfig \
|
||||
fonts-dejavu \
|
||||
fonts-noto-cjk \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /app/publish .
|
||||
# storage dir created + owned for the non-root app user
|
||||
RUN mkdir -p /app/App_Data/storage && chown -R app:app /app/App_Data
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s \
|
||||
CMD curl -fsS http://localhost:8080/health || exit 1
|
||||
ENTRYPOINT ["dotnet", "ROLAC.API.dll"]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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 Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Data;
|
||||
|
||||
public class AuditInterceptorTests
|
||||
{
|
||||
private static AppDbContext BuildDb(AuditSaveChangesInterceptor interceptor)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.AddInterceptors(interceptor)
|
||||
.Options;
|
||||
return new AppDbContext(options);
|
||||
}
|
||||
|
||||
private static AuditSaveChangesInterceptor BuildInterceptor(string userId = "user-1")
|
||||
{
|
||||
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 AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Added_SetsCreatedAtAndCreatedBy()
|
||||
{
|
||||
var interceptor = BuildInterceptor("user-42");
|
||||
using var db = BuildDb(interceptor);
|
||||
|
||||
var member = new Member { FirstName_en = "A", LastName_en = "B" };
|
||||
db.Members.Add(member);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Assert.Equal("user-42", member.CreatedBy);
|
||||
Assert.Equal("user-42", member.UpdatedBy);
|
||||
Assert.True(member.CreatedAt > DateTimeOffset.UtcNow.AddSeconds(-5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Modified_UpdatesUpdatedAtAndUpdatedBy()
|
||||
{
|
||||
var interceptor = BuildInterceptor("user-1");
|
||||
using var db = BuildDb(interceptor);
|
||||
|
||||
var member = new Member { FirstName_en = "A", LastName_en = "B" };
|
||||
db.Members.Add(member);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
member.NickName = "Nick";
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Assert.Equal("user-1", member.UpdatedBy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using ROLAC.API.Services.Disbursement;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class AmountToWordsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0, "Zero and 00/100 Dollars")]
|
||||
[InlineData(0.05, "Zero and 05/100 Dollars")]
|
||||
[InlineData(1, "One and 00/100 Dollars")]
|
||||
[InlineData(19, "Nineteen and 00/100 Dollars")]
|
||||
[InlineData(20, "Twenty and 00/100 Dollars")]
|
||||
[InlineData(21, "Twenty-One and 00/100 Dollars")]
|
||||
[InlineData(100, "One Hundred and 00/100 Dollars")]
|
||||
[InlineData(115, "One Hundred Fifteen and 00/100 Dollars")]
|
||||
[InlineData(1234.56, "One Thousand Two Hundred Thirty-Four and 56/100 Dollars")]
|
||||
[InlineData(1000000, "One Million and 00/100 Dollars")]
|
||||
public void Convert_FormatsExpectedWords(double amount, string expected)
|
||||
{
|
||||
Assert.Equal(expected, AmountToWords.Convert((decimal)amount));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_RoundsCentsHalfUp()
|
||||
{
|
||||
Assert.Equal("One and 00/100 Dollars", AmountToWords.Convert(0.999m));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration;
|
||||
using Moq;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Auth;
|
||||
using ROLAC.API.DTOs.Permissions;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
using Xunit;
|
||||
@@ -33,7 +34,8 @@ public class AuthServiceTests
|
||||
private static Mock<UserManager<AppUser>> BuildUserManager(
|
||||
AppUser? findResult = null,
|
||||
bool passwordOk = true,
|
||||
IList<string>? roles = null)
|
||||
IList<string>? roles = null,
|
||||
IdentityResult? changePasswordResult = null)
|
||||
{
|
||||
var store = new Mock<IUserStore<AppUser>>();
|
||||
// Remaining ctor params are all optional; Moq passes them via reflection.
|
||||
@@ -52,6 +54,9 @@ public class AuthServiceTests
|
||||
.ReturnsAsync(roles ?? new List<string> { "member" });
|
||||
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
mgr.Setup(m => m.ChangePasswordAsync(
|
||||
It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(changePasswordResult ?? IdentityResult.Success);
|
||||
|
||||
return mgr;
|
||||
}
|
||||
@@ -72,11 +77,21 @@ public class AuthServiceTests
|
||||
return svc;
|
||||
}
|
||||
|
||||
/// <summary>IPermissionService mock: returns an empty effective-permission map.</summary>
|
||||
private static Mock<IPermissionService> BuildPermissionService()
|
||||
{
|
||||
var svc = new Mock<IPermissionService>();
|
||||
svc.Setup(p => p.GetEffectivePermissionsAsync(It.IsAny<IEnumerable<string>>()))
|
||||
.ReturnsAsync(new Dictionary<string, ModuleActions>());
|
||||
return svc;
|
||||
}
|
||||
|
||||
private static AuthService BuildSut(
|
||||
Mock<UserManager<AppUser>> umMock,
|
||||
Mock<ITokenService> tsMock,
|
||||
AppDbContext db)
|
||||
=> new(umMock.Object, tsMock.Object, db, BuildConfig());
|
||||
=> new(umMock.Object, tsMock.Object, db, BuildPermissionService().Object,
|
||||
ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance, BuildConfig());
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Login tests
|
||||
@@ -255,4 +270,85 @@ public class AuthServiceTests
|
||||
var token = db.RefreshTokens.Single();
|
||||
Assert.NotNull(token.RevokedAt);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Change password tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ChangePassword_ValidRequest_Succeeds()
|
||||
{
|
||||
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
|
||||
var um = BuildUserManager(findResult: user);
|
||||
var ts = BuildTokenService();
|
||||
var sut = BuildSut(um, ts, BuildDb());
|
||||
|
||||
var result = await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", null);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
um.Verify(m => m.ChangePasswordAsync(user, "Old1234!", "New1234!"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangePassword_UnknownUser_Fails()
|
||||
{
|
||||
var um = BuildUserManager(findResult: null);
|
||||
var ts = BuildTokenService();
|
||||
var sut = BuildSut(um, ts, BuildDb());
|
||||
|
||||
var result = await sut.ChangePasswordAsync("missing", "Old1234!", "New1234!", null);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
um.Verify(m => m.ChangePasswordAsync(
|
||||
It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangePassword_WrongCurrentPassword_ReturnsFailure()
|
||||
{
|
||||
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
|
||||
var failed = IdentityResult.Failed(new IdentityError { Description = "Incorrect password." });
|
||||
var um = BuildUserManager(findResult: user, changePasswordResult: failed);
|
||||
var ts = BuildTokenService();
|
||||
var sut = BuildSut(um, ts, BuildDb());
|
||||
|
||||
var result = await sut.ChangePasswordAsync("u1", "WrongOld!", "New1234!", null);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangePassword_Success_RevokesOtherSessionsButKeepsCurrent()
|
||||
{
|
||||
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
|
||||
var um = BuildUserManager(findResult: user);
|
||||
var ts = BuildTokenService(); // HashToken(x) => "hash:{x}"
|
||||
var db = BuildDb();
|
||||
|
||||
// Current session token (raw "current-raw" => "hash:current-raw")
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
UserId = "u1",
|
||||
TokenHash = "hash:current-raw",
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
||||
CreatedAt = DateTime.UtcNow.AddHours(-1),
|
||||
});
|
||||
// Another active session on a different device
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
UserId = "u1",
|
||||
TokenHash = "hash:other-device",
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
||||
CreatedAt = DateTime.UtcNow.AddHours(-2),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var sut = BuildSut(um, ts, db);
|
||||
await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", "current-raw");
|
||||
|
||||
var current = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:current-raw");
|
||||
var other = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:other-device");
|
||||
Assert.Null(current.RevokedAt); // current session preserved
|
||||
Assert.NotNull(other.RevokedAt); // other session revoked
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Moq;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.Data.Interceptors;
|
||||
using ROLAC.API.DTOs.Disbursement;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
using ROLAC.API.Services.Disbursement;
|
||||
using ROLAC.API.Services.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class DisbursementServiceTests
|
||||
{
|
||||
private sealed class FakeStorage : IFileStorage
|
||||
{
|
||||
public Dictionary<string, byte[]> Files = new();
|
||||
public Task<string> SaveAsync(Stream c, string p, CancellationToken ct = default)
|
||||
{ using var ms = new MemoryStream(); c.CopyTo(ms); Files[p] = ms.ToArray(); return Task.FromResult(p); }
|
||||
public Task<Stream?> OpenReadAsync(string p, CancellationToken ct = default)
|
||||
=> Task.FromResult<Stream?>(Files.TryGetValue(p, out var b) ? new MemoryStream(b) : null);
|
||||
public Task DeleteAsync(string p, CancellationToken ct = default) { Files.Remove(p); return Task.CompletedTask; }
|
||||
}
|
||||
|
||||
private sealed class FakePrint : ICheckPrintService
|
||||
{
|
||||
public CheckPrintModel? LastReceiptModel;
|
||||
|
||||
public Task<Stream> RenderPdfAsync(CheckPrintModel model)
|
||||
=> Task.FromResult<Stream>(new MemoryStream(Encoding.UTF8.GetBytes("pdf")));
|
||||
|
||||
public Task<Stream> RenderReceiptPdfAsync(CheckPrintModel model)
|
||||
{
|
||||
LastReceiptModel = model;
|
||||
return Task.FromResult<Stream>(new MemoryStream(Encoding.UTF8.GetBytes("receipt-pdf")));
|
||||
}
|
||||
}
|
||||
|
||||
private static AppDbContext BuildDb(string userId)
|
||||
{
|
||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
|
||||
var mock = new Mock<IHttpContextAccessor>();
|
||||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
|
||||
}
|
||||
|
||||
private static DisbursementService SvcAs(AppDbContext db, FakeStorage fs, string userId)
|
||||
{
|
||||
var http = new Mock<IHttpContextAccessor>();
|
||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
|
||||
http.Setup(x => x.HttpContext).Returns(ctx);
|
||||
return new DisbursementService(db, http.Object, fs, new FakePrint(), ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||
}
|
||||
|
||||
private static (DisbursementService svc, AppDbContext db, FakeStorage fs) Build(string userId = "fin")
|
||||
{
|
||||
var db = BuildDb(userId);
|
||||
db.ChurchProfiles.Add(new ChurchProfile { Id = 1, Name = "ROLAC", NextCheckNumber = 1001 });
|
||||
db.Members.Add(new Member { Id = 1, FirstName_en = "John", LastName_en = "Doe", Address = "1 Main St", City = "Arcadia", State = "CA", ZipCode = "91006" });
|
||||
db.SaveChanges();
|
||||
var fs = new FakeStorage();
|
||||
return (SvcAs(db, fs, userId), db, fs);
|
||||
}
|
||||
|
||||
private static Expense Approved(string type, decimal amount, int? memberId = null, string? vendor = null) => new()
|
||||
{
|
||||
Type = type, Status = "Approved", Amount = amount, Description = $"{type} {amount}",
|
||||
MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, ExpenseDate = new DateOnly(2026, 6, 1),
|
||||
MemberId = memberId, VendorName = vendor,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task GroupedWorklist_BundlesSamePayee()
|
||||
{
|
||||
var (svc, db, _) = Build();
|
||||
db.Expenses.AddRange(
|
||||
Approved("StaffReimbursement", 10m, memberId: 1),
|
||||
Approved("StaffReimbursement", 15m, memberId: 1),
|
||||
Approved("VendorPayment", 30m, vendor: "Acme"));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var groups = await svc.GetApprovedUnpaidGroupedAsync();
|
||||
|
||||
Assert.Equal(2, groups.Count);
|
||||
var member = groups.Single(g => g.PayeeType == "Member");
|
||||
Assert.Equal(25m, member.TotalAmount);
|
||||
Assert.Equal(2, member.Lines.Count);
|
||||
Assert.Equal("John Doe", member.PayeeName);
|
||||
Assert.Equal("1 Main St", member.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Issue_CreatesOneCheckPerPayee_MarksPaid_SequentialNumbers()
|
||||
{
|
||||
var (svc, db, _) = Build();
|
||||
var e1 = Approved("StaffReimbursement", 10m, memberId: 1);
|
||||
var e2 = Approved("StaffReimbursement", 15m, memberId: 1);
|
||||
var e3 = Approved("VendorPayment", 30m, vendor: "Acme");
|
||||
db.Expenses.AddRange(e1, e2, e3);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var req = new IssueChecksRequest
|
||||
{
|
||||
CheckDate = new DateOnly(2026, 6, 20),
|
||||
Payees =
|
||||
[
|
||||
new() { PayeeType = "Member", MemberId = 1, PayeeName = "John Doe", ExpenseIds = [e1.Id, e2.Id] },
|
||||
new() { PayeeType = "Vendor", VendorKey = "acme", PayeeName = "Acme", ExpenseIds = [e3.Id] },
|
||||
],
|
||||
};
|
||||
|
||||
var result = await svc.IssueChecksAsync(req);
|
||||
|
||||
Assert.Equal(2, result.Created.Count);
|
||||
Assert.Equal(new[] { "1001", "1002" }, result.Created.Select(c => c.CheckNumber).ToArray());
|
||||
Assert.All(await db.Expenses.ToListAsync(), e => Assert.Equal("Paid", e.Status));
|
||||
var memberCheck = await db.Checks.FirstAsync(c => c.PayeeType == "Member");
|
||||
Assert.Equal(25m, memberCheck.Amount);
|
||||
Assert.Equal(1003, (await db.ChurchProfiles.FirstAsync()).NextCheckNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Issue_RejectsNonApprovedExpense()
|
||||
{
|
||||
var (svc, db, _) = Build();
|
||||
var e = Approved("VendorPayment", 30m, vendor: "Acme");
|
||||
e.Status = "Draft";
|
||||
db.Expenses.Add(e);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var req = new IssueChecksRequest
|
||||
{
|
||||
CheckDate = new DateOnly(2026, 6, 20),
|
||||
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.IssueChecksAsync(req));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Void_RevertsExpensesToApproved()
|
||||
{
|
||||
var (svc, db, _) = Build();
|
||||
var e = Approved("VendorPayment", 30m, vendor: "Acme");
|
||||
db.Expenses.Add(e);
|
||||
await db.SaveChangesAsync();
|
||||
var result = await svc.IssueChecksAsync(new IssueChecksRequest
|
||||
{
|
||||
CheckDate = new DateOnly(2026, 6, 20),
|
||||
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
|
||||
});
|
||||
var checkId = result.Created[0].CheckId;
|
||||
|
||||
await svc.VoidAsync(checkId, "wrong amount");
|
||||
|
||||
var check = await db.Checks.FirstAsync(c => c.Id == checkId);
|
||||
Assert.Equal("Voided", check.Status);
|
||||
var reverted = await db.Expenses.FirstAsync(x => x.Id == e.Id);
|
||||
Assert.Equal("Approved", reverted.Status);
|
||||
Assert.Null(reverted.CheckNumber);
|
||||
Assert.Null(reverted.PaidAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acknowledge_StoresSignatureAndTimestamp()
|
||||
{
|
||||
var (svc, db, fs) = Build();
|
||||
var e = Approved("VendorPayment", 30m, vendor: "Acme");
|
||||
db.Expenses.Add(e);
|
||||
await db.SaveChangesAsync();
|
||||
var result = await svc.IssueChecksAsync(new IssueChecksRequest
|
||||
{
|
||||
CheckDate = new DateOnly(2026, 6, 20),
|
||||
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
|
||||
});
|
||||
var checkId = result.Created[0].CheckId;
|
||||
|
||||
using var img = new MemoryStream(Encoding.UTF8.GetBytes("png-bytes"));
|
||||
await svc.AcknowledgeReceiptAsync(checkId, img, "sig.png", "Acme Rep");
|
||||
|
||||
var check = await db.Checks.FirstAsync(c => c.Id == checkId);
|
||||
Assert.NotNull(check.ReceiptSignedAt);
|
||||
Assert.Equal("Acme Rep", check.ReceiptSignedName);
|
||||
Assert.NotNull(check.ReceiptSignatureBlobPath);
|
||||
Assert.Single(fs.Files);
|
||||
}
|
||||
|
||||
private static (DisbursementService svc, AppDbContext db, FakeStorage fs, FakePrint print) BuildWithPrint(string userId = "fin")
|
||||
{
|
||||
var db = BuildDb(userId);
|
||||
db.ChurchProfiles.Add(new ChurchProfile { Id = 1, Name = "ROLAC", NextCheckNumber = 1001 });
|
||||
db.SaveChanges();
|
||||
var fs = new FakeStorage();
|
||||
var print = new FakePrint();
|
||||
var http = new Mock<IHttpContextAccessor>();
|
||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
|
||||
http.Setup(x => x.HttpContext).Returns(ctx);
|
||||
return (new DisbursementService(db, http.Object, fs, print, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance), db, fs, print);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReceiptPdf_NullWhenNotSigned()
|
||||
{
|
||||
var (svc, db, _, _) = BuildWithPrint();
|
||||
var e = Approved("VendorPayment", 30m, vendor: "Acme");
|
||||
db.Expenses.Add(e);
|
||||
await db.SaveChangesAsync();
|
||||
var result = await svc.IssueChecksAsync(new IssueChecksRequest
|
||||
{
|
||||
CheckDate = new DateOnly(2026, 6, 20),
|
||||
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
|
||||
});
|
||||
|
||||
var receipt = await svc.RenderReceiptPdfAsync(result.Created[0].CheckId);
|
||||
|
||||
Assert.Null(receipt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReceiptPdf_AfterSigning_RendersWithSignatureBytes()
|
||||
{
|
||||
var (svc, db, _, print) = BuildWithPrint();
|
||||
var e = Approved("VendorPayment", 30m, vendor: "Acme");
|
||||
db.Expenses.Add(e);
|
||||
await db.SaveChangesAsync();
|
||||
var result = await svc.IssueChecksAsync(new IssueChecksRequest
|
||||
{
|
||||
CheckDate = new DateOnly(2026, 6, 20),
|
||||
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
|
||||
});
|
||||
var checkId = result.Created[0].CheckId;
|
||||
using var img = new MemoryStream(Encoding.UTF8.GetBytes("png-bytes"));
|
||||
await svc.AcknowledgeReceiptAsync(checkId, img, "sig.png", "Acme Rep");
|
||||
|
||||
var receipt = await svc.RenderReceiptPdfAsync(checkId);
|
||||
|
||||
Assert.NotNull(receipt);
|
||||
Assert.Equal("receipt-1001.pdf", receipt!.Value.fileName);
|
||||
Assert.NotNull(print.LastReceiptModel);
|
||||
Assert.NotNull(print.LastReceiptModel!.SignatureImage);
|
||||
Assert.Equal("Acme Rep", print.LastReceiptModel.Check.ReceiptSignedName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acknowledge_VoidedCheck_Throws()
|
||||
{
|
||||
var (svc, db, _) = Build();
|
||||
var e = Approved("VendorPayment", 30m, vendor: "Acme");
|
||||
db.Expenses.Add(e);
|
||||
await db.SaveChangesAsync();
|
||||
var result = await svc.IssueChecksAsync(new IssueChecksRequest
|
||||
{
|
||||
CheckDate = new DateOnly(2026, 6, 20),
|
||||
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
|
||||
});
|
||||
var checkId = result.Created[0].CheckId;
|
||||
await svc.VoidAsync(checkId, null);
|
||||
|
||||
using var img = new MemoryStream(Encoding.UTF8.GetBytes("png"));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => svc.AcknowledgeReceiptAsync(checkId, img, "sig.png", "X"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.Data.Interceptors;
|
||||
using ROLAC.API.DTOs.Expense;
|
||||
using ROLAC.API.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class ExpenseCategoryServiceTests
|
||||
{
|
||||
private static AppDbContext BuildDb()
|
||||
{
|
||||
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") };
|
||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
|
||||
var mock = new Mock<IHttpContextAccessor>();
|
||||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAll_NestsSubcategories_AndExcludesInactiveByDefault()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new ExpenseCategoryService(db);
|
||||
var gid = await svc.CreateGroupAsync(new CreateExpenseGroupRequest { Name_en = "Equipment" });
|
||||
var sid = await svc.CreateSubCategoryAsync(new CreateExpenseSubCategoryRequest { GroupId = gid, Name_en = "Purchase" });
|
||||
await svc.DeactivateSubCategoryAsync(sid);
|
||||
|
||||
var active = await svc.GetAllAsync(includeInactive: false);
|
||||
var all = await svc.GetAllAsync(includeInactive: true);
|
||||
|
||||
Assert.Single(active);
|
||||
Assert.Empty(active[0].SubCategories);
|
||||
Assert.Single(all[0].SubCategories);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeactivateGroup_SetsInactive()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new ExpenseCategoryService(db);
|
||||
var gid = await svc.CreateGroupAsync(new CreateExpenseGroupRequest { Name_en = "Other" });
|
||||
await svc.DeactivateGroupAsync(gid);
|
||||
Assert.Empty(await svc.GetAllAsync(includeInactive: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateGroup_Throws_WhenMissing()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new ExpenseCategoryService(db);
|
||||
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
|
||||
svc.UpdateGroupAsync(999, new UpdateExpenseGroupRequest { Name_en = "X" }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.Data.Interceptors;
|
||||
using ROLAC.API.DTOs.Expense;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
using ROLAC.API.Services.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class ExpenseServiceTests
|
||||
{
|
||||
private sealed class FakeStorage : IFileStorage
|
||||
{
|
||||
public Dictionary<string, byte[]> Files = new();
|
||||
public Task<string> SaveAsync(Stream c, string p, CancellationToken ct = default)
|
||||
{ using var ms = new MemoryStream(); c.CopyTo(ms); Files[p] = ms.ToArray(); return Task.FromResult(p); }
|
||||
public Task<Stream?> OpenReadAsync(string p, CancellationToken ct = default)
|
||||
=> Task.FromResult<Stream?>(Files.TryGetValue(p, out var b) ? new MemoryStream(b) : null);
|
||||
public Task DeleteAsync(string p, CancellationToken ct = default) { Files.Remove(p); return Task.CompletedTask; }
|
||||
}
|
||||
|
||||
private static AppDbContext BuildDb(string userId)
|
||||
{
|
||||
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 AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
|
||||
}
|
||||
|
||||
private static (ExpenseService svc, AppDbContext db, FakeStorage fs) Build(string userId = "u1")
|
||||
{
|
||||
var db = BuildDb(userId);
|
||||
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Worship" });
|
||||
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Equipment" });
|
||||
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Purchase" });
|
||||
db.SaveChanges();
|
||||
var fs = new FakeStorage();
|
||||
return (SvcAs(db, fs, userId), db, fs);
|
||||
}
|
||||
|
||||
private static ExpenseService SvcAs(AppDbContext db, FakeStorage fs, string userId)
|
||||
{
|
||||
var http = new Mock<IHttpContextAccessor>();
|
||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
|
||||
http.Setup(x => x.HttpContext).Returns(ctx);
|
||||
return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||
}
|
||||
|
||||
// Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier),
|
||||
// mirroring the real JWT (NameClaimType="sub", MapInboundClaims=false).
|
||||
private static ExpenseService SvcWithSubClaim(AppDbContext db, FakeStorage fs, string userId)
|
||||
{
|
||||
var http = new Mock<IHttpContextAccessor>();
|
||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim("sub", userId) })) };
|
||||
http.Setup(x => x.HttpContext).Returns(ctx);
|
||||
return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||
}
|
||||
|
||||
private static CreateExpenseRequest Reimb() => new()
|
||||
{
|
||||
Type = "StaffReimbursement", MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1,
|
||||
Amount = 45.50m, Description = "Batteries", ExpenseDate = new DateOnly(2026, 5, 28),
|
||||
};
|
||||
|
||||
private static UpdateExpenseRequest CloneToUpdate(CreateExpenseRequest r) => new()
|
||||
{
|
||||
Type = r.Type, MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId,
|
||||
SubCategoryId = r.SubCategoryId, Amount = r.Amount, Description = r.Description,
|
||||
VendorName = r.VendorName, MemberId = r.MemberId, CheckNumber = r.CheckNumber,
|
||||
ExpenseDate = r.ExpenseDate, Notes = r.Notes,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Reimbursement_ResolvesUserId_FromSubClaim()
|
||||
{
|
||||
// Regression: the real JWT exposes the user id as "sub", not ClaimTypes.NameIdentifier.
|
||||
// SubmittedBy must be the sub value (not "system"), or the self-ownership guard breaks.
|
||||
var db = BuildDb("ignored");
|
||||
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Worship" });
|
||||
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Equipment" });
|
||||
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Purchase" });
|
||||
await db.SaveChangesAsync();
|
||||
var svc = SvcWithSubClaim(db, new FakeStorage(), "user-guid-123");
|
||||
|
||||
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||
|
||||
var e = await db.Expenses.FindAsync(id);
|
||||
Assert.Equal("user-guid-123", e!.SubmittedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Vendor_AsFinance_IsPendingApproval()
|
||||
{
|
||||
var (svc, db, _) = Build();
|
||||
var r = Reimb(); r.Type = "VendorPayment"; r.VendorName = "ABC"; r.CheckNumber = "2051";
|
||||
var id = await svc.CreateAsync(r, isFinance: true);
|
||||
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Reimbursement_AsFinance_OnBehalf_IsPendingApproval_AndLinksPickedMember()
|
||||
{
|
||||
// Finance entering on behalf of a member (member explicitly picked) goes straight to the
|
||||
// approval queue and links the picked member.
|
||||
var (svc, db, _) = Build();
|
||||
db.Members.Add(new Member { Id = 9, FirstName_en = "Pat", LastName_en = "Vendor" });
|
||||
await db.SaveChangesAsync();
|
||||
var r = Reimb(); r.MemberId = 9;
|
||||
|
||||
var id = await svc.CreateAsync(r, isFinance: true);
|
||||
|
||||
var e = await db.Expenses.FindAsync(id);
|
||||
Assert.Equal("PendingApproval", e!.Status);
|
||||
Assert.Equal(9, e.MemberId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Reimbursement_AsFinance_SelfService_LinksCallerMember_AndIsDraft()
|
||||
{
|
||||
// Regression: a finance/super_admin user filing their OWN reimbursement via "My Reimbursements"
|
||||
// sends no MemberId. The entry must link to the caller's own member (so the Payee shows their
|
||||
// legal name) and stay a Draft until they explicitly Submit — not jump to PendingApproval with
|
||||
// a null member.
|
||||
var (svc, db, _) = Build("u1");
|
||||
db.Members.Add(new Member { Id = 7, FirstName_en = "Grace", LastName_en = "Lee" });
|
||||
db.Users.Add(new AppUser { Id = "u1", MemberId = 7 });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var id = await svc.CreateAsync(Reimb(), isFinance: true); // no MemberId on the request
|
||||
|
||||
var e = await db.Expenses.FindAsync(id);
|
||||
Assert.Equal(7, e!.MemberId);
|
||||
Assert.Equal("Draft", e.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Reimbursement_AsMember_IsDraft_WithSubmitter()
|
||||
{
|
||||
var (svc, db, _) = Build("alice");
|
||||
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||
var e = await db.Expenses.FindAsync(id);
|
||||
Assert.Equal("Draft", e!.Status);
|
||||
Assert.Equal("alice", e.SubmittedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StateMachine_HappyPath_Submit_Approve_Pay()
|
||||
{
|
||||
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);
|
||||
await svc.ApproveAsync(id);
|
||||
Assert.Equal("Approved", (await db.Expenses.FindAsync(id))!.Status);
|
||||
await svc.PayAsync(id, "3001", new DateOnly(2026, 6, 1));
|
||||
var paid = await db.Expenses.FindAsync(id);
|
||||
Assert.Equal("Paid", paid!.Status);
|
||||
Assert.Equal("3001", paid.CheckNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approve_FromDraft_Throws()
|
||||
{
|
||||
var (svc, _, _) = Build("alice");
|
||||
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.ApproveAsync(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reject_RecordsNotes_AndStatus()
|
||||
{
|
||||
var (svc, db, _) = Build("alice");
|
||||
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||
await svc.SubmitAsync(id);
|
||||
await svc.RejectAsync(id, "Missing receipt");
|
||||
var e = await db.Expenses.FindAsync(id);
|
||||
Assert.Equal("Rejected", e!.Status);
|
||||
Assert.Equal("Missing receipt", e.ReviewNotes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_OthersDraft_AsNonFinance_Throws()
|
||||
{
|
||||
var (aliceSvc, db, fs) = Build("alice");
|
||||
var id = await aliceSvc.CreateAsync(Reimb(), isFinance: false);
|
||||
var bobSvc = SvcAs(db, fs, "bob");
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
bobSvc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_OwnPendingApproval_AsNonFinance_Succeeds()
|
||||
{
|
||||
// After Submit a reimbursement sits in PendingApproval; the owner may still correct it.
|
||||
var (svc, db, _) = Build("alice");
|
||||
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||
await svc.SubmitAsync(id);
|
||||
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
|
||||
|
||||
var edit = CloneToUpdate(Reimb());
|
||||
edit.Amount = 99.99m;
|
||||
await svc.UpdateAsync(id, edit, isFinance: false);
|
||||
|
||||
var e = await db.Expenses.FindAsync(id);
|
||||
Assert.Equal(99.99m, e!.Amount);
|
||||
Assert.Equal("PendingApproval", e.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_OwnApproved_AsNonFinance_Throws()
|
||||
{
|
||||
// Once approved, the owner can no longer edit.
|
||||
var (svc, db, fs) = Build("alice");
|
||||
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||
await svc.SubmitAsync(id);
|
||||
await SvcAs(db, fs, "finance").ApproveAsync(id);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
svc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveReceipt_OwnPendingApproval_AsNonFinance_Succeeds()
|
||||
{
|
||||
var (svc, db, _) = Build("alice");
|
||||
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||
await svc.SubmitAsync(id);
|
||||
using var input = new MemoryStream(Encoding.UTF8.GetBytes("img"));
|
||||
await svc.SaveReceiptAsync(id, input, "r.jpg", isFinance: false);
|
||||
Assert.NotNull(await svc.OpenReceiptAsync(id, isFinance: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SoftDelete_HidesFromQueries()
|
||||
{
|
||||
var (svc, db, _) = Build("alice");
|
||||
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||
await svc.DeleteAsync(id, isFinance: true);
|
||||
Assert.Null(await db.Expenses.FirstOrDefaultAsync(e => e.Id == id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Receipt_SaveThenOpen_RoundTrips()
|
||||
{
|
||||
var (svc, _, _) = Build("alice");
|
||||
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||
using var input = new MemoryStream(Encoding.UTF8.GetBytes("img"));
|
||||
await svc.SaveReceiptAsync(id, input, "r.jpg", isFinance: false);
|
||||
var got = await svc.OpenReceiptAsync(id, isFinance: true);
|
||||
Assert.NotNull(got);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.Data.Interceptors;
|
||||
using ROLAC.API.DTOs.Giving;
|
||||
using ROLAC.API.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class GivingCategoryServiceTests
|
||||
{
|
||||
private static IHttpContextAccessor 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 mock.Object;
|
||||
}
|
||||
|
||||
private static AppDbContext BuildDb(string userId = "test-user")
|
||||
{
|
||||
var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId)));
|
||||
return new AppDbContext(
|
||||
new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.AddInterceptors(interceptor)
|
||||
.Options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_ReturnsId_AndDefaultsActive()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new GivingCategoryService(db);
|
||||
|
||||
var id = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Tithe", Name_zh = "什一" });
|
||||
|
||||
var saved = await db.GivingCategories.FindAsync(id);
|
||||
Assert.NotNull(saved);
|
||||
Assert.True(saved!.IsActive);
|
||||
Assert.Equal("Tithe", saved.Name_en);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllAsync_ExcludesInactive_ByDefault()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new GivingCategoryService(db);
|
||||
var id1 = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Active" });
|
||||
var id2 = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Gone" });
|
||||
await svc.DeactivateAsync(id2);
|
||||
|
||||
var active = await svc.GetAllAsync(includeInactive: false);
|
||||
var all = await svc.GetAllAsync(includeInactive: true);
|
||||
|
||||
Assert.Single(active);
|
||||
Assert.Equal(2, all.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeactivateAsync_SetsIsActiveFalse()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new GivingCategoryService(db);
|
||||
var id = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Temp" });
|
||||
|
||||
await svc.DeactivateAsync(id);
|
||||
|
||||
var saved = await db.GivingCategories.FindAsync(id);
|
||||
Assert.False(saved!.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_Throws_WhenMissing()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new GivingCategoryService(db);
|
||||
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
|
||||
svc.UpdateAsync(999, new UpdateGivingCategoryRequest { Name_en = "X" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeactivateAsync_Throws_WhenMissing()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new GivingCategoryService(db);
|
||||
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeactivateAsync(999));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.Data.Interceptors;
|
||||
using ROLAC.API.DTOs.Giving;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class GivingServiceTests
|
||||
{
|
||||
private static IHttpContextAccessor 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 mock.Object;
|
||||
}
|
||||
|
||||
private static AppDbContext BuildDb(string userId = "test-user")
|
||||
{
|
||||
var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId)));
|
||||
return new AppDbContext(
|
||||
new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.AddInterceptors(interceptor)
|
||||
.Options);
|
||||
}
|
||||
|
||||
private static async Task<int> SeedCategoryAsync(AppDbContext db)
|
||||
{
|
||||
var c = new GivingCategory { Name_en = "Tithe", IsActive = true };
|
||||
db.GivingCategories.Add(c);
|
||||
await db.SaveChangesAsync();
|
||||
return c.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_PersistsGiving()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var svc = new GivingService(db);
|
||||
|
||||
var id = await svc.CreateAsync(new CreateGivingRequest
|
||||
{
|
||||
GivingCategoryId = catId, Amount = 100m, PaymentMethod = "Cash",
|
||||
GivingDate = new DateOnly(2026, 5, 31), IsAnonymous = true,
|
||||
});
|
||||
|
||||
var saved = await db.Givings.FindAsync(id);
|
||||
Assert.NotNull(saved);
|
||||
Assert.Equal(100m, saved!.Amount);
|
||||
Assert.Null(saved.OfferingSessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_FiltersByCategory()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var svc = new GivingService(db);
|
||||
await svc.CreateAsync(new CreateGivingRequest { GivingCategoryId = catId, Amount = 10m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026,5,31) });
|
||||
|
||||
var page = await svc.GetPagedAsync(1, 20, null, catId, null, null);
|
||||
|
||||
Assert.Equal(1, page.TotalCount);
|
||||
Assert.Equal("Tithe", page.Items[0].CategoryName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_Throws_WhenGivingBelongsToSubmittedSession()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var session = new OfferingSession { SessionDate = new DateOnly(2026,5,31), Status = "Submitted" };
|
||||
db.OfferingSessions.Add(session);
|
||||
await db.SaveChangesAsync();
|
||||
var giving = new Giving { GivingCategoryId = catId, Amount = 50m, PaymentMethod = "Cash",
|
||||
GivingDate = new DateOnly(2026,5,31), OfferingSessionId = session.Id };
|
||||
db.Givings.Add(giving);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var svc = new GivingService(db);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
svc.UpdateAsync(giving.Id, new UpdateGivingRequest
|
||||
{ GivingCategoryId = catId, Amount = 999m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026,5,31) }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_Throws_WhenMissing()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new GivingService(db);
|
||||
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeleteAsync(999));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_Anonymous_NullsProvidedMemberId()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var svc = new GivingService(db);
|
||||
|
||||
var id = await svc.CreateAsync(new CreateGivingRequest
|
||||
{
|
||||
GivingCategoryId = catId, Amount = 25m, PaymentMethod = "Cash",
|
||||
GivingDate = new DateOnly(2026, 5, 31),
|
||||
IsAnonymous = true, MemberId = 12345, // provided, but must be stripped
|
||||
});
|
||||
|
||||
var saved = await db.Givings.FindAsync(id);
|
||||
Assert.True(saved!.IsAnonymous);
|
||||
Assert.Null(saved.MemberId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_Throws_WhenGivingBelongsToSubmittedSession()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var session = new OfferingSession { SessionDate = new DateOnly(2026, 5, 31), Status = "Submitted" };
|
||||
db.OfferingSessions.Add(session);
|
||||
await db.SaveChangesAsync();
|
||||
var giving = new Giving { GivingCategoryId = catId, Amount = 50m, PaymentMethod = "Cash",
|
||||
GivingDate = new DateOnly(2026, 5, 31), OfferingSessionId = session.Id };
|
||||
db.Givings.Add(giving);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var svc = new GivingService(db);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.DeleteAsync(giving.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_MatchesByMemberName()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var member = new Member { FirstName_en = "Grace", LastName_en = "Lee" };
|
||||
db.Members.Add(member);
|
||||
await db.SaveChangesAsync();
|
||||
var svc = new GivingService(db);
|
||||
await svc.CreateAsync(new CreateGivingRequest
|
||||
{
|
||||
GivingCategoryId = catId, Amount = 75m, PaymentMethod = "Cash",
|
||||
GivingDate = new DateOnly(2026, 5, 31), MemberId = member.Id,
|
||||
});
|
||||
|
||||
var page = await svc.GetPagedAsync(1, 20, "grace", null, null, null);
|
||||
|
||||
Assert.Equal(1, page.TotalCount);
|
||||
Assert.Equal(member.Id, page.Items[0].MemberId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using ROLAC.API.Services.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class LocalDiskFileStorageTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "rolac-test-" + Guid.NewGuid());
|
||||
|
||||
private LocalDiskFileStorage Build()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["Storage:LocalRoot"] = _root })
|
||||
.Build();
|
||||
return new LocalDiskFileStorage(config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveThenOpen_RoundTrips()
|
||||
{
|
||||
var fs = Build();
|
||||
using var input = new MemoryStream(Encoding.UTF8.GetBytes("hello"));
|
||||
var path = await fs.SaveAsync(input, "finance/receipts/2026/5/1-r.txt");
|
||||
|
||||
await using var read = await fs.OpenReadAsync(path);
|
||||
Assert.NotNull(read);
|
||||
using var sr = new StreamReader(read!);
|
||||
Assert.Equal("hello", await sr.ReadToEndAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenRead_ReturnsNull_WhenMissing()
|
||||
{
|
||||
var fs = Build();
|
||||
Assert.Null(await fs.OpenReadAsync("finance/receipts/none.txt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_RejectsPathTraversal()
|
||||
{
|
||||
var fs = Build();
|
||||
using var input = new MemoryStream(Encoding.UTF8.GetBytes("x"));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => fs.SaveAsync(input, "../escape.txt"));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
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.DTOs.Members;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class MemberServiceTests
|
||||
{
|
||||
private static IHttpContextAccessor 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 mock.Object;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an InMemory AppDbContext that includes the AuditSaveChangesInterceptor
|
||||
/// so that CreatedBy/UpdatedBy are stamped on save (required by InMemory null checks).
|
||||
/// </summary>
|
||||
private static AppDbContext BuildDb(string userId = "test-user")
|
||||
{
|
||||
var accessor = BuildAccessor(userId);
|
||||
var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(accessor));
|
||||
return new AppDbContext(
|
||||
new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.AddInterceptors(interceptor)
|
||||
.Options);
|
||||
}
|
||||
|
||||
// ── Create ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_ReturnsNewId()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new MemberService(db, BuildAccessor());
|
||||
var request = new CreateMemberRequest { FirstName_en = "Chris", LastName_en = "Chen" };
|
||||
|
||||
var id = await svc.CreateAsync(request);
|
||||
|
||||
Assert.True(id > 0);
|
||||
var saved = await db.Members.FindAsync(id);
|
||||
Assert.NotNull(saved);
|
||||
Assert.Equal("Chris", saved.FirstName_en);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_SavesNickName()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new MemberService(db, BuildAccessor());
|
||||
var request = new CreateMemberRequest
|
||||
{ FirstName_en = "Yuan", LastName_en = "Chen", NickName = "Chris" };
|
||||
|
||||
var id = await svc.CreateAsync(request);
|
||||
var saved = await db.Members.FindAsync(id);
|
||||
|
||||
Assert.Equal("Chris", saved!.NickName);
|
||||
}
|
||||
|
||||
// ── GetById ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsDto_WhenExists()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new MemberService(db, BuildAccessor());
|
||||
var id = await svc.CreateAsync(
|
||||
new CreateMemberRequest { FirstName_en = "A", LastName_en = "B" });
|
||||
|
||||
var dto = await svc.GetByIdAsync(id);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.Equal(id, dto.Id);
|
||||
Assert.Equal("A", dto.FirstName_en);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsNull_WhenNotFound()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new MemberService(db, BuildAccessor());
|
||||
|
||||
var dto = await svc.GetByIdAsync(9999);
|
||||
|
||||
Assert.Null(dto);
|
||||
}
|
||||
|
||||
// ── GetPaged ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_FiltersOnSearch()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new MemberService(db, BuildAccessor());
|
||||
await svc.CreateAsync(new CreateMemberRequest { FirstName_en = "Chris", LastName_en = "Chen" });
|
||||
await svc.CreateAsync(new CreateMemberRequest { FirstName_en = "Alice", LastName_en = "Wang" });
|
||||
|
||||
var result = await svc.GetPagedAsync(1, 20, "Chris", null, null);
|
||||
|
||||
Assert.Equal(1, result.TotalCount);
|
||||
Assert.Equal("Chris", result.Items[0].FirstName_en);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_FiltersOnStatus()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new MemberService(db, BuildAccessor());
|
||||
await svc.CreateAsync(new CreateMemberRequest
|
||||
{ FirstName_en = "A", LastName_en = "A", Status = "Member" });
|
||||
await svc.CreateAsync(new CreateMemberRequest
|
||||
{ FirstName_en = "B", LastName_en = "B", Status = "Visitor" });
|
||||
|
||||
var result = await svc.GetPagedAsync(1, 20, null, "Visitor", null);
|
||||
|
||||
Assert.Equal(1, result.TotalCount);
|
||||
Assert.Equal("Visitor", result.Items[0].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_SearchesNickName()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new MemberService(db, BuildAccessor());
|
||||
await svc.CreateAsync(new CreateMemberRequest
|
||||
{ FirstName_en = "Yuan", LastName_en = "Chen", NickName = "Chris" });
|
||||
|
||||
var result = await svc.GetPagedAsync(1, 20, "Chris", null, null);
|
||||
|
||||
Assert.Equal(1, result.TotalCount);
|
||||
}
|
||||
|
||||
// ── Update ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_PersistsChanges()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new MemberService(db, BuildAccessor());
|
||||
var id = await svc.CreateAsync(
|
||||
new CreateMemberRequest { FirstName_en = "Old", LastName_en = "Name" });
|
||||
|
||||
await svc.UpdateAsync(id, new UpdateMemberRequest
|
||||
{ FirstName_en = "New", LastName_en = "Name", Country = "USA",
|
||||
Status = "Member", LanguagePreference = "en" });
|
||||
|
||||
var saved = await db.Members.FindAsync(id);
|
||||
Assert.Equal("New", saved!.FirstName_en);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_ThrowsKeyNotFound_WhenMissing()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new MemberService(db, BuildAccessor());
|
||||
|
||||
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
|
||||
svc.UpdateAsync(9999, new UpdateMemberRequest
|
||||
{ FirstName_en = "X", LastName_en = "Y", Country = "USA",
|
||||
Status = "Member", LanguagePreference = "en" }));
|
||||
}
|
||||
|
||||
// ── Delete (soft) ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_SoftDeletesMember()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new MemberService(db, BuildAccessor("deleter-id"));
|
||||
var id = await svc.CreateAsync(
|
||||
new CreateMemberRequest { FirstName_en = "A", LastName_en = "B" });
|
||||
|
||||
await svc.DeleteAsync(id);
|
||||
|
||||
// Query-filtered view returns null
|
||||
var filtered = await db.Members.FindAsync(id);
|
||||
Assert.Null(filtered);
|
||||
|
||||
// Raw view shows IsDeleted = true
|
||||
var raw = await db.Members.IgnoreQueryFilters()
|
||||
.FirstAsync(m => m.Id == id);
|
||||
Assert.True(raw.IsDeleted);
|
||||
Assert.Equal("deleter-id", raw.DeletedBy);
|
||||
Assert.NotNull(raw.DeletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_ThrowsKeyNotFound_WhenMissing()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new MemberService(db, BuildAccessor());
|
||||
|
||||
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeleteAsync(9999));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.Data.Interceptors;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class MinistryServiceTests
|
||||
{
|
||||
private static AppDbContext BuildDb()
|
||||
{
|
||||
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") };
|
||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
|
||||
var mock = new Mock<IHttpContextAccessor>();
|
||||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllAsync_OrdersBySortOrder_AndExcludesInactive()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
db.Ministries.AddRange(
|
||||
new Ministry { Name_en = "B", SortOrder = 2, IsActive = true },
|
||||
new Ministry { Name_en = "A", SortOrder = 1, IsActive = true },
|
||||
new Ministry { Name_en = "Z", SortOrder = 3, IsActive = false });
|
||||
await db.SaveChangesAsync();
|
||||
var svc = new MinistryService(db);
|
||||
|
||||
var active = await svc.GetAllAsync(includeInactive: false);
|
||||
var all = await svc.GetAllAsync(includeInactive: true);
|
||||
|
||||
Assert.Equal(2, active.Count);
|
||||
Assert.Equal("A", active[0].Name_en);
|
||||
Assert.Equal(3, all.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.Data.Interceptors;
|
||||
using ROLAC.API.DTOs.Expense;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class MonthlyStatementServiceTests
|
||||
{
|
||||
private static AppDbContext BuildDb()
|
||||
{
|
||||
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") };
|
||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
|
||||
var mock = new Mock<IHttpContextAccessor>();
|
||||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
|
||||
}
|
||||
|
||||
private static MonthlyStatementService Build(AppDbContext db)
|
||||
{
|
||||
var mock = new Mock<IHttpContextAccessor>();
|
||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) };
|
||||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||
return new MonthlyStatementService(db, mock.Object, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_ComputesGivingAndPaidExpenses_ForMonthOnly()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
db.GivingCategories.Add(new GivingCategory { Id = 1, Name_en = "Tithe" });
|
||||
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Admin" });
|
||||
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Other" });
|
||||
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Misc" });
|
||||
db.Givings.Add(new Giving { GivingCategoryId = 1, Amount = 1000m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026, 5, 10) });
|
||||
db.Givings.Add(new Giving { GivingCategoryId = 1, Amount = 500m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026, 6, 1) });
|
||||
db.Expenses.Add(new Expense { MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Type = "VendorPayment", Status = "Paid", Amount = 300m, Description = "x", ExpenseDate = new DateOnly(2026, 5, 20) });
|
||||
db.Expenses.Add(new Expense { MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Type = "StaffReimbursement", Status = "Approved", Amount = 999m, Description = "not paid", ExpenseDate = new DateOnly(2026, 5, 21) });
|
||||
await db.SaveChangesAsync();
|
||||
var svc = Build(db);
|
||||
|
||||
var id = await svc.CreateAsync(new CreateMonthlyStatementRequest
|
||||
{ Year = 2026, Month = 5, OpeningBalance = 2000m, TotalOtherIncome = 100m, BankStatementBalance = 2800m });
|
||||
|
||||
var dto = await svc.GetByIdAsync(id);
|
||||
Assert.Equal(1000m, dto!.TotalGiving);
|
||||
Assert.Equal(300m, dto.TotalExpenses);
|
||||
Assert.Equal(2800m, dto.CalculatedClosingBalance);
|
||||
Assert.Equal(0m, dto.Difference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Duplicate_Throws()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = Build(db);
|
||||
await svc.CreateAsync(new CreateMonthlyStatementRequest { Year = 2026, Month = 5 });
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
svc.CreateAsync(new CreateMonthlyStatementRequest { Year = 2026, Month = 5 }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_AfterFinalize_Throws()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = Build(db);
|
||||
var id = await svc.CreateAsync(new CreateMonthlyStatementRequest { Year = 2026, Month = 5 });
|
||||
await svc.FinalizeAsync(id);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
svc.UpdateAsync(id, new UpdateMonthlyStatementRequest { OpeningBalance = 1m }));
|
||||
}
|
||||
}
|
||||
@@ -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, ""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.Data.Interceptors;
|
||||
using ROLAC.API.DTOs.Giving;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
using ROLAC.API.Services.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class OfferingSessionServiceTests
|
||||
{
|
||||
// Proof storage is not exercised by these tests; a no-op keeps the service constructible.
|
||||
private sealed class NoOpFileStorage : IFileStorage
|
||||
{
|
||||
public Task<string> SaveAsync(Stream content, string relativePath, CancellationToken ct = default)
|
||||
=> Task.FromResult(relativePath);
|
||||
public Task<Stream?> OpenReadAsync(string relativePath, CancellationToken ct = default)
|
||||
=> Task.FromResult<Stream?>(null);
|
||||
public Task DeleteAsync(string relativePath, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static IHttpContextAccessor 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 mock.Object;
|
||||
}
|
||||
|
||||
private static AppDbContext BuildDb(string userId = "test-user")
|
||||
{
|
||||
var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId)));
|
||||
return new AppDbContext(
|
||||
new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.AddInterceptors(interceptor)
|
||||
.Options);
|
||||
}
|
||||
|
||||
private static async Task<int> SeedCategoryAsync(AppDbContext db)
|
||||
{
|
||||
var c = new GivingCategory { Name_en = "Tithe", IsActive = true };
|
||||
db.GivingCategories.Add(c);
|
||||
await db.SaveChangesAsync();
|
||||
return c.Id;
|
||||
}
|
||||
|
||||
private static CreateOfferingSessionRequest BuildRequest(int catId, DateOnly date) => new()
|
||||
{
|
||||
SessionDate = date, CashTotal = 150m, CheckTotal = 0m,
|
||||
Givings =
|
||||
[
|
||||
new() { GivingCategoryId = catId, Amount = 100m, PaymentMethod = "Cash" },
|
||||
new() { GivingCategoryId = catId, Amount = 50m, PaymentMethod = "Cash", IsAnonymous = true },
|
||||
],
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_RecomputesSystemTotalAndDifference_ServerSide()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||
|
||||
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||
|
||||
var saved = await db.OfferingSessions.FindAsync(id);
|
||||
Assert.Equal("Submitted", saved!.Status);
|
||||
Assert.Equal(150m, saved.SystemTotal);
|
||||
Assert.Equal(0m, saved.Difference);
|
||||
Assert.NotNull(saved.SubmittedAt);
|
||||
Assert.Equal(2, await db.Givings.CountAsync(g => g.OfferingSessionId == id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_LinesGetSessionDateAndAnonymousNullsMember()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||
|
||||
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||
|
||||
var lines = await db.Givings.Where(g => g.OfferingSessionId == id).ToListAsync();
|
||||
Assert.All(lines, l => Assert.Equal(new DateOnly(2026,5,31), l.GivingDate));
|
||||
Assert.Contains(lines, l => l.IsAnonymous && l.MemberId == null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_Throws_OnDuplicateSessionDate()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||
await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplaceAsync_Throws_WhenSessionIsSubmitted()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
svc.ReplaceAsync(id, BuildRequest(catId, new DateOnly(2026, 5, 31))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReopenThenReplace_SwapsLinesAndResubmits()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||
|
||||
await svc.ReopenAsync(id);
|
||||
var reopened = await db.OfferingSessions.FindAsync(id);
|
||||
Assert.Equal("Draft", reopened!.Status);
|
||||
|
||||
var newReq = new CreateOfferingSessionRequest
|
||||
{
|
||||
SessionDate = new DateOnly(2026,5,31), CashTotal = 200m, CheckTotal = 0m,
|
||||
Givings = [ new() { GivingCategoryId = catId, Amount = 200m, PaymentMethod = "Cash" } ],
|
||||
};
|
||||
await svc.ReplaceAsync(id, newReq);
|
||||
|
||||
var after = await db.OfferingSessions.FindAsync(id);
|
||||
Assert.Equal("Submitted", after!.Status);
|
||||
Assert.Equal(200m, after.SystemTotal);
|
||||
Assert.Equal(1, await db.Givings.CountAsync(g => g.OfferingSessionId == id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsCheckZelleAndPayPalRefs()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||
var req = new CreateOfferingSessionRequest
|
||||
{
|
||||
SessionDate = new DateOnly(2026, 6, 7), CashTotal = 0m, CheckTotal = 100m,
|
||||
Givings = [ new() { GivingCategoryId = catId, Amount = 100m, PaymentMethod = "Zelle",
|
||||
ZelleReferenceCode = "Z-123", PayPalTransactionId = "PP-456", CheckNumber = "C-789" } ],
|
||||
};
|
||||
var id = await svc.CreateAsync(req);
|
||||
|
||||
var dto = await svc.GetByIdAsync(id);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
var line = Assert.Single(dto!.Givings);
|
||||
Assert.Equal("Z-123", line.ZelleReferenceCode);
|
||||
Assert.Equal("PP-456", line.PayPalTransactionId);
|
||||
Assert.Equal("C-789", line.CheckNumber);
|
||||
}
|
||||
}
|
||||
@@ -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 }]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Users;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class UserManagementServiceTests
|
||||
{
|
||||
private static AppDbContext BuildDb() =>
|
||||
new(new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options);
|
||||
|
||||
private static Mock<UserManager<AppUser>> BuildUserManager(
|
||||
AppUser? findResult = null,
|
||||
bool createOk = true,
|
||||
IList<string>? roles = null)
|
||||
{
|
||||
var store = new Mock<IUserStore<AppUser>>();
|
||||
#pragma warning disable CS8625
|
||||
var mgr = new Mock<UserManager<AppUser>>(
|
||||
store.Object, null, null, null, null, null, null, null, null);
|
||||
#pragma warning restore CS8625
|
||||
mgr.Setup(m => m.FindByIdAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync(findResult);
|
||||
mgr.Setup(m => m.FindByEmailAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((AppUser?)null);
|
||||
mgr.Setup(m => m.CreateAsync(It.IsAny<AppUser>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(createOk ? IdentityResult.Success
|
||||
: IdentityResult.Failed(new IdentityError { Description = "fail" }));
|
||||
mgr.Setup(m => m.AddToRolesAsync(It.IsAny<AppUser>(), It.IsAny<IEnumerable<string>>()))
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
mgr.Setup(m => m.GetRolesAsync(It.IsAny<AppUser>()))
|
||||
.ReturnsAsync(roles ?? new List<string> { "member" });
|
||||
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
mgr.Setup(m => m.RemoveFromRolesAsync(It.IsAny<AppUser>(), It.IsAny<IEnumerable<string>>()))
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
mgr.Setup(m => m.GeneratePasswordResetTokenAsync(It.IsAny<AppUser>()))
|
||||
.ReturnsAsync("reset-token");
|
||||
mgr.Setup(m => m.ResetPasswordAsync(It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
return mgr;
|
||||
}
|
||||
|
||||
// ── CreateAsync ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_ReturnsTempPassword()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
// Seed a Member so MemberId validation passes
|
||||
// Note: InMemory DB requires audit fields — we set them directly
|
||||
var member = new Member
|
||||
{
|
||||
FirstName_en = "A", LastName_en = "B",
|
||||
CreatedBy = "system", UpdatedBy = "system",
|
||||
CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.Members.Add(member);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var mgr = BuildUserManager();
|
||||
// Capture the AppUser passed to CreateAsync
|
||||
AppUser? created = null;
|
||||
mgr.Setup(m => m.CreateAsync(It.IsAny<AppUser>(), It.IsAny<string>()))
|
||||
.Callback<AppUser, string>((u, _) => { created = u; u.Id = Guid.NewGuid().ToString(); })
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
// Mock Users queryable to return empty (no existing user for this member)
|
||||
mgr.Setup(m => m.Users)
|
||||
.Returns(new List<AppUser>().AsQueryable());
|
||||
|
||||
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||
var result = await svc.CreateAsync(new CreateUserRequest
|
||||
{
|
||||
MemberId = member.Id,
|
||||
Email = "test@rolac.org",
|
||||
Roles = ["member"],
|
||||
});
|
||||
|
||||
Assert.False(string.IsNullOrEmpty(result.TempPassword));
|
||||
Assert.Equal(12, result.TempPassword.Length);
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal(member.Id, created!.MemberId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_Throws_WhenMemberNotFound()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var mgr = BuildUserManager();
|
||||
mgr.Setup(m => m.Users)
|
||||
.Returns(new List<AppUser>().AsQueryable());
|
||||
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
svc.CreateAsync(new CreateUserRequest
|
||||
{ MemberId = 9999, Email = "x@y.com", Roles = ["member"] }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_Throws_WhenMemberAlreadyHasUser()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var member = new Member
|
||||
{
|
||||
FirstName_en = "A", LastName_en = "B",
|
||||
CreatedBy = "system", UpdatedBy = "system",
|
||||
CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.Members.Add(member);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var existingUser = new AppUser
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
UserName = "existing@test.com",
|
||||
Email = "existing@test.com",
|
||||
MemberId = member.Id,
|
||||
};
|
||||
db.Users.Add(existingUser);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var mgr = BuildUserManager();
|
||||
// The service checks _userManager.Users — we need to return the existing user
|
||||
mgr.Setup(m => m.Users)
|
||||
.Returns(new List<AppUser> { existingUser }.AsQueryable());
|
||||
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
svc.CreateAsync(new CreateUserRequest
|
||||
{ MemberId = member.Id, Email = "new@test.com", Roles = ["member"] }));
|
||||
}
|
||||
|
||||
// ── DeactivateAsync ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DeactivateAsync_SetsIsActiveFalse()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var user = new AppUser
|
||||
{ Id = "u1", UserName = "a@b.com", Email = "a@b.com", IsActive = true };
|
||||
var mgr = BuildUserManager(findResult: user);
|
||||
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||
|
||||
await svc.DeactivateAsync("u1");
|
||||
|
||||
Assert.False(user.IsActive);
|
||||
Assert.Equal(DateTimeOffset.MaxValue, user.LockoutEnd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeactivateAsync_ThrowsKeyNotFound_WhenUserMissing()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var mgr = BuildUserManager(findResult: null);
|
||||
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeactivateAsync("missing"));
|
||||
}
|
||||
|
||||
// ── ResetPasswordAsync ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ResetPasswordAsync_ReturnsNewTempPassword()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var user = new AppUser { Id = "u1", UserName = "a@b.com", Email = "a@b.com" };
|
||||
var mgr = BuildUserManager(findResult: user);
|
||||
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||
|
||||
var pwd = await svc.ResetPasswordAsync("u1");
|
||||
|
||||
Assert.Equal(12, pwd.Length);
|
||||
}
|
||||
}
|
||||
@@ -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.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.DTOs.Auth;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
@@ -13,11 +16,14 @@ public class AuthController : ControllerBase
|
||||
private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
|
||||
|
||||
private readonly IAuthService _authService;
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly IWebHostEnvironment _env;
|
||||
|
||||
public AuthController(IAuthService authService, IWebHostEnvironment env)
|
||||
public AuthController(
|
||||
IAuthService authService, UserManager<AppUser> userManager, IWebHostEnvironment env)
|
||||
{
|
||||
_authService = authService;
|
||||
_userManager = userManager;
|
||||
_env = env;
|
||||
}
|
||||
|
||||
@@ -78,6 +84,58 @@ public class AuthController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GET /api/auth/me
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current user's identity, roles, and effective permissions.
|
||||
/// The SPA calls this on startup and after an admin edits the permission matrix
|
||||
/// to refresh what the UI shows — without forcing a re-login.
|
||||
/// </summary>
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(UserInfo), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetMe()
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
return Unauthorized();
|
||||
|
||||
var user = await _userManager.FindByIdAsync(userId);
|
||||
if (user is null || !user.IsActive)
|
||||
return Unauthorized();
|
||||
|
||||
var roles = await _userManager.GetRolesAsync(user);
|
||||
return Ok(await _authService.BuildUserInfoAsync(user, roles));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GET /api/auth/claims (dev-only diagnostic)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Returns the raw claims ASP.NET Core parsed from the Bearer token.
|
||||
/// Use this to debug 401 vs 403: if you get 200 here, the JWT validates fine;
|
||||
/// if you then get 403 on a protected endpoint the role/permission isn't matching.
|
||||
/// </summary>
|
||||
[HttpGet("claims")]
|
||||
[Authorize]
|
||||
public IActionResult GetClaims()
|
||||
{
|
||||
var claims = User.Claims
|
||||
.Select(c => new { c.Type, c.Value })
|
||||
.ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
isAuthenticated = User.Identity?.IsAuthenticated,
|
||||
authenticationType = User.Identity?.AuthenticationType,
|
||||
name = User.Identity?.Name,
|
||||
claims,
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// POST /api/auth/logout
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -96,6 +154,38 @@ public class AuthController : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// POST /api/auth/change-password
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Changes the current user's password. Requires the correct current password and a
|
||||
/// new password meeting the configured policy. On success the user's *other* sessions
|
||||
/// are revoked while the current session stays active.
|
||||
/// </summary>
|
||||
[HttpPost("change-password")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
return Unauthorized();
|
||||
|
||||
var currentRefresh = Request.Cookies[CookieName];
|
||||
var result = await _authService.ChangePasswordAsync(
|
||||
userId, request.CurrentPassword, request.NewPassword, currentRefresh);
|
||||
|
||||
if (!result.Succeeded)
|
||||
return BadRequest(new
|
||||
{
|
||||
message = string.Join(" ", result.Errors.Select(error => error.Description)),
|
||||
});
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Disbursement;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/church-profile")]
|
||||
[Authorize]
|
||||
public class ChurchProfileController : ControllerBase
|
||||
{
|
||||
private readonly IChurchProfileService _svc;
|
||||
public ChurchProfileController(IChurchProfileService svc) => _svc = svc;
|
||||
|
||||
[HttpGet]
|
||||
[HasPermission(Modules.ChurchProfile, PermissionActions.Read)]
|
||||
public async Task<IActionResult> Get() => Ok(await _svc.GetAsync());
|
||||
|
||||
[HttpPut]
|
||||
[HasPermission(Modules.ChurchProfile, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Update([FromBody] UpdateChurchProfileRequest r)
|
||||
{
|
||||
await _svc.UpdateAsync(r);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Disbursement;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/disbursements")]
|
||||
[Authorize]
|
||||
public class DisbursementsController : ControllerBase
|
||||
{
|
||||
private readonly IDisbursementService _svc;
|
||||
public DisbursementsController(IDisbursementService svc) => _svc = svc;
|
||||
|
||||
[HttpGet("approved-unpaid")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetApprovedUnpaid()
|
||||
=> Ok(await _svc.GetApprovedUnpaidGroupedAsync());
|
||||
|
||||
[HttpPost("issue")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Issue([FromBody] IssueChecksRequest r)
|
||||
{
|
||||
try { return Ok(await _svc.IssueChecksAsync(r)); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpGet("checks")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetRegister(
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? status = null,
|
||||
[FromQuery] string? search = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
||||
=> Ok(await _svc.GetRegisterAsync(page, pageSize, status, search, from, to));
|
||||
|
||||
[HttpGet("checks/{id:int}")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
var dto = await _svc.GetByIdAsync(id);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
[HttpPost("checks/{id:int}/void")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> Void(int id, [FromBody] VoidCheckRequest r)
|
||||
{
|
||||
try { await _svc.VoidAsync(id, r.Reason); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpGet("checks/{id:int}/pdf")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetPdf(int id)
|
||||
{
|
||||
var result = await _svc.RenderPdfAsync(id);
|
||||
if (result is null) return NotFound();
|
||||
return File(result.Value.stream, result.Value.contentType, result.Value.fileName);
|
||||
}
|
||||
|
||||
[HttpGet("checks/{id:int}/receipt-pdf")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetReceiptPdf(int id)
|
||||
{
|
||||
var result = await _svc.RenderReceiptPdfAsync(id);
|
||||
if (result is null) return NotFound();
|
||||
return File(result.Value.stream, result.Value.contentType, result.Value.fileName);
|
||||
}
|
||||
|
||||
[HttpPost("checks/{id:int}/acknowledge")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Approve)]
|
||||
[RequestSizeLimit(5_242_880)]
|
||||
public async Task<IActionResult> Acknowledge(int id, [FromForm] IFormFile signature, [FromForm] string signedName)
|
||||
{
|
||||
if (signature is null || signature.Length == 0) return BadRequest(new { message = "No signature." });
|
||||
if (string.IsNullOrWhiteSpace(signedName)) return BadRequest(new { message = "Signed name is required." });
|
||||
var allowed = new[] { "image/png", "image/jpeg", "image/webp" };
|
||||
if (!allowed.Contains(signature.ContentType)) return BadRequest(new { message = "Unsupported image type." });
|
||||
try
|
||||
{
|
||||
await using var stream = signature.OpenReadStream();
|
||||
await _svc.AcknowledgeReceiptAsync(id, stream, signature.FileName, signedName.Trim());
|
||||
return NoContent();
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpGet("checks/{id:int}/signature")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetSignature(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _svc.OpenSignatureAsync(id);
|
||||
if (result is null) return NotFound();
|
||||
return File(result.Value.stream, result.Value.contentType);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Expense;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/expense-categories")]
|
||||
[Authorize] // read (GetAll) is open to any authenticated user — the member self-service
|
||||
// reimbursement form needs the category list. Write actions are finance-only below.
|
||||
public class ExpenseCategoriesController : ControllerBase
|
||||
{
|
||||
private readonly IExpenseCategoryService _svc;
|
||||
public ExpenseCategoriesController(IExpenseCategoryService svc) => _svc = svc;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
||||
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||
|
||||
[HttpPost("groups")]
|
||||
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||
public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)
|
||||
=> Ok(new { id = await _svc.CreateGroupAsync(r) });
|
||||
|
||||
[HttpPut("groups/{id:int}")]
|
||||
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateExpenseGroupRequest r)
|
||||
{ try { await _svc.UpdateGroupAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||
|
||||
[HttpDelete("groups/{id:int}")]
|
||||
[HasPermission(Modules.ExpenseCategories, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> DeactivateGroup(int id)
|
||||
{ try { await _svc.DeactivateGroupAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||
|
||||
[HttpPost("subcategories")]
|
||||
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||
public async Task<IActionResult> CreateSub([FromBody] CreateExpenseSubCategoryRequest r)
|
||||
{ try { return Ok(new { id = await _svc.CreateSubCategoryAsync(r) }); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||
|
||||
[HttpPut("subcategories/{id:int}")]
|
||||
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||
public async Task<IActionResult> UpdateSub(int id, [FromBody] UpdateExpenseSubCategoryRequest r)
|
||||
{ try { await _svc.UpdateSubCategoryAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||
|
||||
[HttpDelete("subcategories/{id:int}")]
|
||||
[HasPermission(Modules.ExpenseCategories, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> DeactivateSub(int id)
|
||||
{ try { await _svc.DeactivateSubCategoryAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Expense;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
// Class is [Authorize] only — any authenticated member may submit/view their OWN
|
||||
// reimbursements. Finance-level privileges (view-all, edit-any, approve) are resolved
|
||||
// against the configurable permission matrix on the "Expenses" module.
|
||||
[ApiController]
|
||||
[Route("api/expenses")]
|
||||
[Authorize]
|
||||
public class ExpensesController : ControllerBase
|
||||
{
|
||||
private readonly IExpenseService _svc;
|
||||
private readonly IPermissionService _perms;
|
||||
public ExpensesController(IExpenseService svc, IPermissionService perms)
|
||||
{
|
||||
_svc = svc;
|
||||
_perms = perms;
|
||||
}
|
||||
|
||||
private List<string> Roles() => User.FindAll("role").Select(claim => claim.Value).ToList();
|
||||
private bool IsSuperAdmin() => User.IsInRole(PermissionAuthorizationHandler.SuperAdminRole);
|
||||
|
||||
// Can manage any expense (edit/delete/upload on others' records). Maps to Expenses:Write.
|
||||
private async Task<bool> CanManageAsync() =>
|
||||
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Write);
|
||||
|
||||
// Can view all expenses (not just own). Maps to Expenses:Read (finance + pastor by default).
|
||||
private async Task<bool> CanViewAllAsync() =>
|
||||
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Read);
|
||||
|
||||
// User id lives in the "sub" claim (NameClaimType="sub"); NameIdentifier is absent at runtime.
|
||||
private string CurrentUserId() =>
|
||||
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "";
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetPaged(
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? search = null,
|
||||
[FromQuery] int? ministryId = null, [FromQuery] int? categoryGroupId = null,
|
||||
[FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null,
|
||||
[FromQuery] int? subCategoryId = null, [FromQuery] string? statuses = null)
|
||||
{
|
||||
if (!await CanViewAllAsync()) return Forbid();
|
||||
return Ok(await _svc.GetPagedAsync(page, pageSize, search, ministryId, categoryGroupId, status, from, to, subCategoryId, statuses));
|
||||
}
|
||||
|
||||
[HttpGet("mine")]
|
||||
public async Task<IActionResult> GetMine([FromQuery] string? status = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||
{
|
||||
return Ok(await _svc.GetMineAsync(CurrentUserId(), status, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
var dto = await _svc.GetByIdAsync(id);
|
||||
if (dto is null) return NotFound();
|
||||
if (!await CanViewAllAsync() && dto.SubmittedBy != CurrentUserId()) return Forbid();
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateExpenseRequest r)
|
||||
{
|
||||
try { return Ok(new { id = await _svc.CreateAsync(r, await CanManageAsync()) }); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateExpenseRequest r)
|
||||
{
|
||||
try { await _svc.UpdateAsync(id, r, await CanManageAsync()); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
try { await _svc.DeleteAsync(id, await CanManageAsync()); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/submit")]
|
||||
public async Task<IActionResult> Submit(int id)
|
||||
{
|
||||
try { await _svc.SubmitAsync(id); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/approve")]
|
||||
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
|
||||
public async Task<IActionResult> Approve(int id)
|
||||
{
|
||||
try { await _svc.ApproveAsync(id); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/reject")]
|
||||
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
|
||||
public async Task<IActionResult> Reject(int id, [FromBody] RejectExpenseRequest r)
|
||||
{
|
||||
try { await _svc.RejectAsync(id, r.ReviewNotes); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/pay")]
|
||||
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
|
||||
public async Task<IActionResult> Pay(int id, [FromBody] PayExpenseRequest r)
|
||||
{
|
||||
try { await _svc.PayAsync(id, r.CheckNumber, r.PaidAt); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/receipt")]
|
||||
[RequestSizeLimit(10_485_760)]
|
||||
public async Task<IActionResult> UploadReceipt(int id, IFormFile file)
|
||||
{
|
||||
if (file is null || file.Length == 0) return BadRequest(new { message = "No file." });
|
||||
var allowed = new[] { "image/jpeg", "image/png", "image/webp", "application/pdf" };
|
||||
if (!allowed.Contains(file.ContentType)) return BadRequest(new { message = "Unsupported file type." });
|
||||
try
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
await _svc.SaveReceiptAsync(id, stream, file.FileName, await CanManageAsync());
|
||||
return NoContent();
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/receipt")]
|
||||
public async Task<IActionResult> GetReceipt(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _svc.OpenReceiptAsync(id, await CanManageAsync());
|
||||
if (result is null) return NotFound();
|
||||
return File(result.Value.stream, result.Value.contentType);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException) { return Forbid(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/finance-dashboard")]
|
||||
[HasPermission(Modules.FinanceDashboard, PermissionActions.Read)]
|
||||
public class FinanceDashboardController : ControllerBase
|
||||
{
|
||||
private readonly IFinanceDashboardService _svc;
|
||||
public FinanceDashboardController(IFinanceDashboardService svc) => _svc = svc;
|
||||
|
||||
[HttpGet("summary")]
|
||||
public async Task<IActionResult> Summary()
|
||||
=> Ok(await _svc.GetSummaryAsync());
|
||||
|
||||
[HttpGet("income-expense")]
|
||||
public async Task<IActionResult> IncomeExpense([FromQuery] DateOnly? from, [FromQuery] DateOnly? to)
|
||||
=> Ok(await _svc.GetIncomeExpenseAsync(from, to));
|
||||
|
||||
[HttpGet("expense-breakdown")]
|
||||
public async Task<IActionResult> ExpenseBreakdown(
|
||||
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
|
||||
[FromQuery] int? ministryId, [FromQuery] int? categoryGroupId)
|
||||
=> Ok(await _svc.GetExpenseBreakdownAsync(from, to, ministryId, categoryGroupId));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Giving;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/giving-categories")]
|
||||
[Authorize]
|
||||
public class GivingCategoriesController : ControllerBase
|
||||
{
|
||||
private readonly IGivingCategoryService _svc;
|
||||
public GivingCategoriesController(IGivingCategoryService svc) => _svc = svc;
|
||||
|
||||
[HttpGet]
|
||||
[HasPermission(Modules.GivingCategories, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
||||
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||
|
||||
[HttpPost]
|
||||
[HasPermission(Modules.GivingCategories, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateGivingCategoryRequest request)
|
||||
{
|
||||
var id = await _svc.CreateAsync(request);
|
||||
return CreatedAtAction(nameof(GetAll), new { id }, new { id });
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
[HasPermission(Modules.GivingCategories, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingCategoryRequest request)
|
||||
{
|
||||
try { await _svc.UpdateAsync(id, request); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
[HasPermission(Modules.GivingCategories, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> Deactivate(int id)
|
||||
{
|
||||
try { await _svc.DeactivateAsync(id); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Giving;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/givings")]
|
||||
[Authorize]
|
||||
public class GivingsController : ControllerBase
|
||||
{
|
||||
private readonly IGivingService _svc;
|
||||
public GivingsController(IGivingService svc) => _svc = svc;
|
||||
|
||||
[HttpGet]
|
||||
[HasPermission(Modules.Givings, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetPaged(
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? search = null, [FromQuery] int? categoryId = null,
|
||||
[FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
||||
=> Ok(await _svc.GetPagedAsync(page, pageSize, search, categoryId, from, to));
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
[HasPermission(Modules.Givings, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
var dto = await _svc.GetByIdAsync(id);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[HasPermission(Modules.Givings, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateGivingRequest request)
|
||||
{
|
||||
var id = await _svc.CreateAsync(request);
|
||||
return CreatedAtAction(nameof(GetById), new { id }, new { id });
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
[HasPermission(Modules.Givings, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingRequest request)
|
||||
{
|
||||
try { await _svc.UpdateAsync(id, request); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
[HasPermission(Modules.Givings, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
try { await _svc.DeleteAsync(id); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Data;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/health")]
|
||||
[AllowAnonymous]
|
||||
public class HealthController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
public HealthController(AppDbContext db) => _db = db;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
var canConnectToDatabase = await _db.Database.CanConnectAsync(cancellationToken);
|
||||
|
||||
var payload = new
|
||||
{
|
||||
status = canConnectToDatabase ? "healthy" : "degraded",
|
||||
database = canConnectToDatabase ? "up" : "down",
|
||||
time = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
return canConnectToDatabase ? Ok(payload) : StatusCode(503, payload);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/meal-attendance")]
|
||||
public class MealAttendanceController : ControllerBase
|
||||
{
|
||||
private readonly IMealAttendanceService _svc;
|
||||
|
||||
public MealAttendanceController(IMealAttendanceService svc) => _svc = svc;
|
||||
|
||||
/// <summary>Today's live counts. Public — feeds the volunteer counter page on first load.</summary>
|
||||
[HttpGet("today")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetToday()
|
||||
=> Ok(await _svc.GetOrCreateAsync(_svc.ServiceDay));
|
||||
|
||||
/// <summary>Daily counts within a date range, for the back-office dashboard chart.</summary>
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetRange([FromQuery] DateOnly from, [FromQuery] DateOnly to)
|
||||
=> Ok(await _svc.GetRangeAsync(from, to));
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Members;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/members")]
|
||||
[Authorize]
|
||||
public class MembersController : ControllerBase
|
||||
{
|
||||
private readonly IMemberService _members;
|
||||
public MembersController(IMemberService members) => _members = members;
|
||||
|
||||
/// <summary>GET /api/members?page=1&pageSize=20&search=Chen&status=Member&hasUser=false</summary>
|
||||
[HttpGet]
|
||||
[HasPermission(Modules.Members, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetPaged(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] string? status = null,
|
||||
[FromQuery] bool? hasUser = null)
|
||||
=> Ok(await _members.GetPagedAsync(page, pageSize, search, status, hasUser));
|
||||
|
||||
/// <summary>GET /api/members/{id}</summary>
|
||||
[HttpGet("{id:int}")]
|
||||
[HasPermission(Modules.Members, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
var dto = await _members.GetByIdAsync(id);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>POST /api/members</summary>
|
||||
[HttpPost]
|
||||
[HasPermission(Modules.Members, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateMemberRequest request)
|
||||
{
|
||||
var id = await _members.CreateAsync(request);
|
||||
return CreatedAtAction(nameof(GetById), new { id }, new { id });
|
||||
}
|
||||
|
||||
/// <summary>PUT /api/members/{id}</summary>
|
||||
[HttpPut("{id:int}")]
|
||||
[HasPermission(Modules.Members, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateMemberRequest request)
|
||||
{
|
||||
try { await _members.UpdateAsync(id, request); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
}
|
||||
|
||||
/// <summary>DELETE /api/members/{id} — soft delete</summary>
|
||||
[HttpDelete("{id:int}")]
|
||||
[HasPermission(Modules.Members, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
try { await _members.DeleteAsync(id); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/ministries")]
|
||||
[Authorize]
|
||||
public class MinistriesController : ControllerBase
|
||||
{
|
||||
private readonly IMinistryService _svc;
|
||||
public MinistriesController(IMinistryService svc) => _svc = svc;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
||||
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Expense;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/monthly-statements")]
|
||||
[Authorize]
|
||||
public class MonthlyStatementsController : ControllerBase
|
||||
{
|
||||
private readonly IMonthlyStatementService _svc;
|
||||
public MonthlyStatementsController(IMonthlyStatementService svc) => _svc = svc;
|
||||
|
||||
[HttpGet]
|
||||
[HasPermission(Modules.MonthlyStatements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetAll([FromQuery] int? year = null)
|
||||
=> Ok(await _svc.GetAllAsync(year));
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
[HasPermission(Modules.MonthlyStatements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
var dto = await _svc.GetByIdAsync(id);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[HasPermission(Modules.MonthlyStatements, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateMonthlyStatementRequest r)
|
||||
{
|
||||
try { return Ok(new { id = await _svc.CreateAsync(r) }); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
[HasPermission(Modules.MonthlyStatements, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateMonthlyStatementRequest r)
|
||||
{
|
||||
try { await _svc.UpdateAsync(id, r); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/finalize")]
|
||||
[HasPermission(Modules.MonthlyStatements, PermissionActions.Approve)]
|
||||
public async Task<IActionResult> Finalize(int id)
|
||||
{
|
||||
try { await _svc.FinalizeAsync(id); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using ROLAC.API.DTOs.Giving;
|
||||
using ROLAC.API.DTOs.Members;
|
||||
using ROLAC.API.Hubs;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Anonymous endpoints powering the mobile Sunday offering-entry page. The page
|
||||
/// has no login yet, so it cannot reach the auth-gated members/categories/
|
||||
/// offering-sessions APIs — these expose just what it needs (active categories,
|
||||
/// a name-only member typeahead, and append-one-line).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/offering-entry")]
|
||||
[AllowAnonymous]
|
||||
public class OfferingEntryController : ControllerBase
|
||||
{
|
||||
private readonly IOfferingSessionService _sessions;
|
||||
private readonly IGivingCategoryService _categories;
|
||||
private readonly IMemberService _members;
|
||||
private readonly IHubContext<OfferingEntryHub> _hub;
|
||||
|
||||
public OfferingEntryController(
|
||||
IOfferingSessionService sessions,
|
||||
IGivingCategoryService categories,
|
||||
IMemberService members,
|
||||
IHubContext<OfferingEntryHub> hub)
|
||||
{
|
||||
_sessions = sessions;
|
||||
_categories = categories;
|
||||
_members = members;
|
||||
_hub = hub;
|
||||
}
|
||||
|
||||
// Seed the page in one round-trip: active categories + today's session state.
|
||||
[HttpGet("bootstrap")]
|
||||
public async Task<IActionResult> Bootstrap([FromQuery] DateOnly date)
|
||||
=> Ok(new OfferingEntryBootstrapDto
|
||||
{
|
||||
SessionDate = date.ToString("yyyy-MM-dd"),
|
||||
Categories = await _categories.GetAllAsync(false),
|
||||
Summary = await _sessions.GetEntrySummaryAsync(date),
|
||||
});
|
||||
|
||||
// Name-only member suggestions for the giver typeahead.
|
||||
[HttpGet("members")]
|
||||
public async Task<IActionResult> SearchMembers([FromQuery] string? search, [FromQuery] int take = 10)
|
||||
=> Ok(await _sessions.SearchMembersForEntryAsync(search, Math.Clamp(take, 1, 25)));
|
||||
|
||||
// Quick-add a giver who isn't on file yet (created as a Visitor). Reuses the
|
||||
// member service directly — role checks live on MembersController, so this
|
||||
// anonymous path is the intended public entry point for the mobile page.
|
||||
[HttpPost("members")]
|
||||
public async Task<IActionResult> QuickAddMember([FromBody] QuickAddMemberRequest request)
|
||||
{
|
||||
var id = await _members.CreateAsync(new CreateMemberRequest
|
||||
{
|
||||
FirstName_en = request.FirstName_en,
|
||||
LastName_en = request.LastName_en,
|
||||
NickName = request.NickName,
|
||||
FirstName_zh = request.FirstName_zh,
|
||||
LastName_zh = request.LastName_zh,
|
||||
PhoneCell = request.PhoneCell,
|
||||
Status = "Visitor",
|
||||
Country = "USA",
|
||||
LanguagePreference = "en",
|
||||
});
|
||||
return Ok(new MemberTypeaheadDto
|
||||
{
|
||||
Id = id, NickName = request.NickName,
|
||||
FirstName_en = request.FirstName_en, LastName_en = request.LastName_en,
|
||||
});
|
||||
}
|
||||
|
||||
// Append one offering line to the date's session (find-or-create), then
|
||||
// broadcast it to everyone viewing that date.
|
||||
[HttpPost("lines")]
|
||||
public async Task<IActionResult> AppendLine([FromBody] AppendOfferingLineRequest request)
|
||||
{
|
||||
var result = await _sessions.AppendLineAsync(request.Date, request.Line);
|
||||
await _hub.Clients.Group(result.SessionDate).SendAsync("LineAdded", result);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── Paper-proof PDF for the date's session (merged client-side) ──────────
|
||||
// Date-keyed so the anonymous page (which has no session id) can attach the
|
||||
// count sheet / envelope photos. Mirrors OfferingSessionsController's proof
|
||||
// validation; the desktop session page reviews/deletes the result.
|
||||
|
||||
[HttpGet("proof")]
|
||||
public async Task<IActionResult> GetProof([FromQuery] DateOnly date)
|
||||
{
|
||||
var result = await _sessions.OpenProofForDateAsync(date);
|
||||
if (result is null) return NoContent(); // no session/proof yet — client merges nothing
|
||||
return File(result.Value.stream, result.Value.contentType);
|
||||
}
|
||||
|
||||
[HttpPost("proof")]
|
||||
[RequestSizeLimit(52_428_800)] // 50 MB — a merged multi-image PDF is larger than one receipt
|
||||
public async Task<IActionResult> UploadProof([FromForm] DateOnly date, IFormFile file)
|
||||
{
|
||||
if (file is null || file.Length == 0) return BadRequest(new { message = "No file." });
|
||||
if (file.ContentType != "application/pdf") return BadRequest(new { message = "Proof must be a PDF." });
|
||||
await using var stream = file.OpenReadStream();
|
||||
await _sessions.SaveProofForDateAsync(date, stream, file.FileName);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Giving;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/offering-sessions")]
|
||||
[Authorize]
|
||||
public class OfferingSessionsController : ControllerBase
|
||||
{
|
||||
private readonly IOfferingSessionService _svc;
|
||||
public OfferingSessionsController(IOfferingSessionService svc) => _svc = svc;
|
||||
|
||||
[HttpGet]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetPaged(
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
||||
[FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
||||
=> Ok(await _svc.GetPagedAsync(page, pageSize, from, to));
|
||||
|
||||
[HttpGet("check-date")]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||
public async Task<IActionResult> CheckDate([FromQuery] DateOnly date)
|
||||
=> Ok(new { exists = await _svc.DateExistsAsync(date) });
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
var dto = await _svc.GetByIdAsync(id);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateOfferingSessionRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var id = await _svc.CreateAsync(request);
|
||||
return CreatedAtAction(nameof(GetById), new { id }, new { id });
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/reopen")]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Approve)]
|
||||
public async Task<IActionResult> Reopen(int id)
|
||||
{
|
||||
try { await _svc.ReopenAsync(id); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Replace(int id, [FromBody] CreateOfferingSessionRequest request)
|
||||
{
|
||||
try { await _svc.ReplaceAsync(id, request); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
// ── Paper-proof PDF (merged client-side, one file per session) ───────────
|
||||
|
||||
[HttpPost("{id:int}/proof")]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
|
||||
[RequestSizeLimit(52_428_800)] // 50 MB — a merged multi-image PDF is larger than one receipt
|
||||
public async Task<IActionResult> UploadProof(int id, IFormFile file)
|
||||
{
|
||||
if (file is null || file.Length == 0) return BadRequest(new { message = "No file." });
|
||||
if (file.ContentType != "application/pdf") return BadRequest(new { message = "Proof must be a PDF." });
|
||||
try
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
await _svc.SaveProofAsync(id, stream, file.FileName);
|
||||
return NoContent();
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/proof")]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetProof(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _svc.OpenProofAsync(id);
|
||||
if (result is null) return NotFound();
|
||||
return File(result.Value.stream, result.Value.contentType);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}/proof")]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> DeleteProof(int id)
|
||||
{
|
||||
try { await _svc.DeleteProofAsync(id); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Users;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/users")]
|
||||
[Authorize]
|
||||
public class UsersController : ControllerBase
|
||||
{
|
||||
private readonly IUserManagementService _users;
|
||||
public UsersController(IUserManagementService users) => _users = users;
|
||||
|
||||
/// <summary>GET /api/users?page=1&pageSize=20&search=Chris</summary>
|
||||
[HttpGet]
|
||||
[HasPermission(Modules.Users, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetPaged(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? search = null)
|
||||
=> Ok(await _users.GetPagedAsync(page, pageSize, search));
|
||||
|
||||
/// <summary>GET /api/users/{id}</summary>
|
||||
[HttpGet("{id}")]
|
||||
[HasPermission(Modules.Users, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetById(string id)
|
||||
{
|
||||
var dto = await _users.GetByIdAsync(id);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/users — creates account for a Member, returns { userId, tempPassword }.
|
||||
/// TempPassword is returned ONCE — show it to the admin and never log it.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateUserRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _users.CreateAsync(request);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>PUT /api/users/{id} — update email, roles, IsActive</summary>
|
||||
[HttpPut("{id}")]
|
||||
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Update(string id, [FromBody] UpdateUserRequest request)
|
||||
{
|
||||
try { await _users.UpdateAsync(id, request); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
/// <summary>DELETE /api/users/{id} — deactivates account (IsActive=false), does not delete</summary>
|
||||
[HttpDelete("{id}")]
|
||||
[HasPermission(Modules.Users, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> Deactivate(string id)
|
||||
{
|
||||
try { await _users.DeactivateAsync(id); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
}
|
||||
|
||||
/// <summary>POST /api/users/{id}/reset-password — returns new temp password</summary>
|
||||
[HttpPost("{id}/reset-password")]
|
||||
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||
public async Task<IActionResult> ResetPassword(string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pwd = await _users.ResetPasswordAsync(id);
|
||||
return Ok(new { tempPassword = pwd });
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
public class LoginResponse
|
||||
@@ -17,4 +19,10 @@ public class UserInfo
|
||||
public string Email { get; set; } = null!;
|
||||
public IList<string> Roles { get; set; } = [];
|
||||
public string LanguagePreference { get; set; } = "en";
|
||||
|
||||
/// <summary>
|
||||
/// Effective permissions (union across the user's roles), keyed by module name.
|
||||
/// Lets the SPA hide nav/buttons. Authoritative enforcement is server-side.
|
||||
/// </summary>
|
||||
public Dictionary<string, ModuleActions> Permissions { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace ROLAC.API.DTOs.Disbursement;
|
||||
|
||||
public class ChurchProfileDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? ZipCode { get; set; }
|
||||
public string? BankName { get; set; }
|
||||
public string? BankAccountNumber { get; set; }
|
||||
public string? BankRoutingNumber { get; set; }
|
||||
public int NextCheckNumber { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateChurchProfileRequest
|
||||
{
|
||||
[Required, MaxLength(200)] public string Name { get; set; } = "";
|
||||
[MaxLength(500)] public string? Address { get; set; }
|
||||
[MaxLength(100)] public string? City { get; set; }
|
||||
[MaxLength(50)] public string? State { get; set; }
|
||||
[MaxLength(20)] public string? ZipCode { get; set; }
|
||||
[MaxLength(200)] public string? BankName { get; set; }
|
||||
[MaxLength(50)] public string? BankAccountNumber { get; set; }
|
||||
[MaxLength(50)] public string? BankRoutingNumber { get; set; }
|
||||
[Range(1, int.MaxValue)] public int NextCheckNumber { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace ROLAC.API.DTOs.Disbursement;
|
||||
|
||||
// ── Approved-unpaid expenses, grouped by payee (the issue-check worklist) ──────
|
||||
|
||||
public class ExpenseLineDto
|
||||
{
|
||||
public int ExpenseId { get; set; }
|
||||
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
|
||||
public string Description { get; set; } = "";
|
||||
public decimal Amount { get; set; }
|
||||
public string MinistryName { get; set; } = "";
|
||||
public string CategoryName { get; set; } = "";
|
||||
}
|
||||
|
||||
public class PayeeGroupDto
|
||||
{
|
||||
public string PayeeType { get; set; } = "Vendor"; // Vendor | Member
|
||||
public int? MemberId { get; set; }
|
||||
public string? VendorKey { get; set; } // normalized vendor name (grouping key)
|
||||
public string PayeeName { get; set; } = "";
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Zip { get; set; }
|
||||
public decimal TotalAmount { get; set; }
|
||||
public List<ExpenseLineDto> Lines { get; set; } = [];
|
||||
}
|
||||
|
||||
// ── Issue checks ──────────────────────────────────────────────────────────────
|
||||
|
||||
public class PayeeCheckInstruction
|
||||
{
|
||||
[Required] public string PayeeType { get; set; } = "Vendor";
|
||||
public int? MemberId { get; set; }
|
||||
public string? VendorKey { get; set; }
|
||||
[Required, MaxLength(200)] public string PayeeName { get; set; } = "";
|
||||
[MaxLength(500)] public string? Address { get; set; }
|
||||
[MaxLength(100)] public string? City { get; set; }
|
||||
[MaxLength(50)] public string? State { get; set; }
|
||||
[MaxLength(20)] public string? Zip { get; set; }
|
||||
[MaxLength(50)] public string? CheckNumberOverride { get; set; }
|
||||
[MaxLength(500)] public string? Memo { get; set; }
|
||||
[Required, MinLength(1)] public List<int> ExpenseIds { get; set; } = [];
|
||||
}
|
||||
|
||||
public class IssueChecksRequest
|
||||
{
|
||||
[Required] public DateOnly CheckDate { get; set; }
|
||||
[Required, MinLength(1)] public List<PayeeCheckInstruction> Payees { get; set; } = [];
|
||||
}
|
||||
|
||||
public class IssuedCheckDto
|
||||
{
|
||||
public int CheckId { get; set; }
|
||||
public string CheckNumber { get; set; } = "";
|
||||
public string PayeeName { get; set; } = "";
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
public class IssueChecksResultDto
|
||||
{
|
||||
public List<IssuedCheckDto> Created { get; set; } = [];
|
||||
}
|
||||
|
||||
// ── Check register / detail ───────────────────────────────────────────────────
|
||||
|
||||
public class CheckListItemDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string CheckNumber { get; set; } = "";
|
||||
public string CheckDate { get; set; } = ""; // yyyy-MM-dd
|
||||
public decimal Amount { get; set; }
|
||||
public string PayeeType { get; set; } = "";
|
||||
public string PayeeName { get; set; } = "";
|
||||
public string Status { get; set; } = "";
|
||||
public int LineCount { get; set; }
|
||||
public bool Signed { get; set; }
|
||||
public string? ReceiptSignedName { get; set; }
|
||||
public DateTimeOffset? ReceiptSignedAt { get; set; }
|
||||
}
|
||||
|
||||
public class CheckLineDto
|
||||
{
|
||||
public int ExpenseId { get; set; }
|
||||
public string Description { get; set; } = "";
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
public class CheckDetailDto : CheckListItemDto
|
||||
{
|
||||
public int? MemberId { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Zip { get; set; }
|
||||
public string? Memo { get; set; }
|
||||
public string? VoidReason { get; set; }
|
||||
public DateTimeOffset? VoidedAt { get; set; }
|
||||
public DateTimeOffset IssuedAt { get; set; }
|
||||
public List<CheckLineDto> Lines { get; set; } = [];
|
||||
}
|
||||
|
||||
public class VoidCheckRequest
|
||||
{
|
||||
[MaxLength(500)] public string? Reason { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace ROLAC.API.DTOs.Expense;
|
||||
|
||||
public class ExpenseSubCategoryDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int GroupId { get; set; }
|
||||
public string Name_en { get; set; } = "";
|
||||
public string? Name_zh { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class ExpenseCategoryGroupDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name_en { get; set; } = "";
|
||||
public string? Name_zh { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public List<ExpenseSubCategoryDto> SubCategories { get; set; } = [];
|
||||
}
|
||||
|
||||
public class CreateExpenseGroupRequest
|
||||
{
|
||||
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
||||
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest
|
||||
{
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
public class CreateExpenseSubCategoryRequest
|
||||
{
|
||||
[Required] public int GroupId { get; set; }
|
||||
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
||||
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest
|
||||
{
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace ROLAC.API.DTOs.Expense;
|
||||
|
||||
public class ExpenseListItemDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Type { get; set; } = "";
|
||||
public string Status { get; set; } = "";
|
||||
public decimal Amount { get; set; }
|
||||
public string Description { get; set; } = "";
|
||||
public int MinistryId { get; set; }
|
||||
public string MinistryName { get; set; } = "";
|
||||
public int CategoryGroupId { get; set; }
|
||||
public string CategoryGroupName { get; set; } = "";
|
||||
public int SubCategoryId { get; set; }
|
||||
public string SubCategoryName { get; set; } = "";
|
||||
public string? VendorName { get; set; }
|
||||
public int? MemberId { get; set; }
|
||||
public string? MemberName { get; set; }
|
||||
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
|
||||
public bool HasReceipt { get; set; }
|
||||
public string? CheckNumber { get; set; }
|
||||
}
|
||||
|
||||
public class ExpenseDto : ExpenseListItemDto
|
||||
{
|
||||
public string? Notes { get; set; }
|
||||
public string? ReviewNotes { get; set; }
|
||||
public string? SubmittedBy { get; set; }
|
||||
public DateTimeOffset? SubmittedAt { get; set; }
|
||||
public DateTimeOffset? ReviewedAt { get; set; }
|
||||
public DateTimeOffset? PaidAt { get; set; }
|
||||
}
|
||||
|
||||
public class CreateExpenseRequest
|
||||
{
|
||||
[Required] public string Type { get; set; } = "StaffReimbursement"; // VendorPayment|StaffReimbursement
|
||||
[Required] public int MinistryId { get; set; }
|
||||
[Required] public int CategoryGroupId { get; set; }
|
||||
[Required] public int SubCategoryId { get; set; }
|
||||
[Range(0.01, 9_999_999)] public decimal Amount { get; set; }
|
||||
[Required, MaxLength(500)] public string Description { get; set; } = "";
|
||||
[MaxLength(200)] public string? VendorName { get; set; }
|
||||
public int? MemberId { get; set; } // ignored for self-service (server uses caller)
|
||||
[MaxLength(50)] public string? CheckNumber { get; set; }
|
||||
[Required] public DateOnly ExpenseDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
public class UpdateExpenseRequest : CreateExpenseRequest { }
|
||||
|
||||
public class RejectExpenseRequest
|
||||
{
|
||||
[MaxLength(500)] public string? ReviewNotes { get; set; }
|
||||
}
|
||||
public class PayExpenseRequest
|
||||
{
|
||||
[MaxLength(50)] public string? CheckNumber { get; set; }
|
||||
public DateOnly? PaidAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace ROLAC.API.DTOs.Expense;
|
||||
|
||||
public class MonthlyStatementDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int Year { get; set; }
|
||||
public int Month { get; set; }
|
||||
public decimal OpeningBalance { get; set; }
|
||||
public decimal TotalGiving { get; set; }
|
||||
public decimal TotalOtherIncome { get; set; }
|
||||
public decimal TotalExpenses { get; set; }
|
||||
public decimal CalculatedClosingBalance { get; set; }
|
||||
public decimal BankStatementBalance { get; set; }
|
||||
public decimal Difference { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsFinalized { get; set; }
|
||||
}
|
||||
|
||||
public class CreateMonthlyStatementRequest
|
||||
{
|
||||
[Range(2000, 2100)] public int Year { get; set; }
|
||||
[Range(1, 12)] public int Month { get; set; }
|
||||
public decimal OpeningBalance { get; set; }
|
||||
public decimal TotalOtherIncome { get; set; }
|
||||
public decimal BankStatementBalance { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
public class UpdateMonthlyStatementRequest
|
||||
{
|
||||
public decimal OpeningBalance { get; set; }
|
||||
public decimal TotalOtherIncome { get; set; }
|
||||
public decimal BankStatementBalance { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ROLAC.API.DTOs.Finance;
|
||||
|
||||
/// <summary>All-time finance position for the dashboard balance card.</summary>
|
||||
public class FinanceSummaryDto
|
||||
{
|
||||
public decimal TotalIncome { get; set; } // all-time sum of Giving.Amount
|
||||
public decimal TotalExpenses { get; set; } // all-time Paid+Approved expenses
|
||||
public decimal Balance { get; set; } // TotalIncome - TotalExpenses
|
||||
}
|
||||
|
||||
/// <summary>Income vs expense totals for a date range (the income/expense pie).</summary>
|
||||
public class IncomeExpenseDto
|
||||
{
|
||||
public decimal Income { get; set; } // Givings in [from,to]
|
||||
public decimal Expense { get; set; } // Paid+Approved expenses in [from,to]
|
||||
}
|
||||
|
||||
/// <summary>One slice of the expense drill-down pie. Id is a ministry / group / sub-category id by level.</summary>
|
||||
public class BreakdownSliceDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name_en { get; set; } = "";
|
||||
public string? Name_zh { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace ROLAC.API.DTOs.Giving;
|
||||
|
||||
// Body of POST /api/offering-entry/lines — one offering line plus the date of the
|
||||
// session it belongs to (find-or-create that day's session, append the line).
|
||||
public class AppendOfferingLineRequest
|
||||
{
|
||||
[Required] public DateOnly Date { get; set; }
|
||||
[Required] public OfferingGivingLineRequest Line { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace ROLAC.API.DTOs.Giving;
|
||||
|
||||
public class CreateGivingCategoryRequest
|
||||
{
|
||||
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
||||
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||
[MaxLength(500)] public string? Description_en { get; set; }
|
||||
[MaxLength(500)] public string? Description_zh { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace ROLAC.API.DTOs.Giving;
|
||||
|
||||
public class CreateGivingRequest
|
||||
{
|
||||
public int? MemberId { get; set; }
|
||||
[Required] public int GivingCategoryId { get; set; }
|
||||
[Range(0.01, 9999999)] public decimal Amount { get; set; }
|
||||
[Required, MaxLength(20)] public string PaymentMethod { get; set; } = "Cash";
|
||||
[MaxLength(50)] public string? CheckNumber { get; set; }
|
||||
[MaxLength(100)] public string? ZelleReferenceCode { get; set; }
|
||||
[MaxLength(100)] public string? PayPalTransactionId { get; set; }
|
||||
public DateOnly GivingDate { get; set; }
|
||||
public bool IsAnonymous { get; set; }
|
||||
[MaxLength(500)] public string? Notes { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace ROLAC.API.DTOs.Giving;
|
||||
|
||||
public class CreateOfferingSessionRequest
|
||||
{
|
||||
[Required] public DateOnly SessionDate { get; set; }
|
||||
public decimal CashTotal { get; set; }
|
||||
public decimal CheckTotal { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public List<OfferingGivingLineRequest> Givings { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ROLAC.API.DTOs.Giving;
|
||||
|
||||
public class GivingCategoryDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name_en { get; set; } = "";
|
||||
public string? Name_zh { get; set; }
|
||||
public string? Description_en { get; set; }
|
||||
public string? Description_zh { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ROLAC.API.DTOs.Giving;
|
||||
|
||||
public class GivingDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int? MemberId { get; set; }
|
||||
public string? MemberName { get; set; }
|
||||
public int GivingCategoryId { get; set; }
|
||||
public int? OfferingSessionId { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public string PaymentMethod { get; set; } = "";
|
||||
public string? CheckNumber { get; set; }
|
||||
public string? ZelleReferenceCode { get; set; }
|
||||
public string? PayPalTransactionId { get; set; }
|
||||
public DateOnly GivingDate { get; set; }
|
||||
public bool IsAnonymous { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace ROLAC.API.DTOs.Giving;
|
||||
|
||||
public class GivingListItemDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int? MemberId { get; set; }
|
||||
public string? MemberName { get; set; }
|
||||
public int GivingCategoryId { get; set; }
|
||||
public string CategoryName { get; set; } = "";
|
||||
public decimal Amount { get; set; }
|
||||
public string PaymentMethod { get; set; } = "";
|
||||
public string GivingDate { get; set; } = ""; // ISO yyyy-MM-dd
|
||||
public bool IsAnonymous { get; set; }
|
||||
public int? OfferingSessionId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ROLAC.API.DTOs.Giving;
|
||||
|
||||
// Minimal member fields exposed to the anonymous mobile offering-entry page —
|
||||
// just enough for the giver typeahead to render a display name (matches the
|
||||
// Angular memberDisplayName helper: NickName ?? FirstName_en, plus LastName_en).
|
||||
public class MemberTypeaheadDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? NickName { get; set; }
|
||||
public string FirstName_en { get; set; } = "";
|
||||
public string LastName_en { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ROLAC.API.DTOs.Giving;
|
||||
|
||||
// One-shot payload that seeds the mobile offering-entry page: the active giving
|
||||
// categories for the Type dropdown and the current state of today's session.
|
||||
public class OfferingEntryBootstrapDto
|
||||
{
|
||||
public string SessionDate { get; set; } = ""; // yyyy-MM-dd
|
||||
public List<GivingCategoryDto> Categories { get; set; } = [];
|
||||
public OfferingEntrySummaryDto Summary { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ROLAC.API.DTOs.Giving;
|
||||
|
||||
// Returned from POST /api/offering-entry/lines and broadcast over the
|
||||
// OfferingEntryHub: the line just added plus the session's new running totals,
|
||||
// so every connected client (other phones + the desktop page) can update live.
|
||||
public class OfferingEntryLineAddedDto
|
||||
{
|
||||
public int SessionId { get; set; }
|
||||
public string SessionDate { get; set; } = ""; // yyyy-MM-dd
|
||||
public string Status { get; set; } = "";
|
||||
public decimal SystemTotal { get; set; }
|
||||
public int LineCount { get; set; }
|
||||
public OfferingGivingLineDto Line { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace ROLAC.API.DTOs.Giving;
|
||||
|
||||
// A day's offering session as the mobile page sees it: the running total/line
|
||||
// count plus the lines already recorded. SessionId is null when no session
|
||||
// exists for the date yet (nothing entered today).
|
||||
public class OfferingEntrySummaryDto
|
||||
{
|
||||
public int? SessionId { get; set; }
|
||||
public string SessionDate { get; set; } = ""; // yyyy-MM-dd
|
||||
public string? Status { get; set; } // null when no session yet
|
||||
public decimal SystemTotal { get; set; }
|
||||
public int LineCount { get; set; }
|
||||
public bool HasProof { get; set; } // a merged paper-proof PDF is attached to this session
|
||||
public List<OfferingGivingLineDto> Lines { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ROLAC.API.DTOs.Giving;
|
||||
|
||||
public class OfferingGivingLineDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int? MemberId { get; set; }
|
||||
public string? MemberName { get; set; }
|
||||
public int GivingCategoryId { get; set; }
|
||||
public string CategoryName { get; set; } = "";
|
||||
public decimal Amount { get; set; }
|
||||
public string PaymentMethod { get; set; } = "";
|
||||
public string? CheckNumber { get; set; }
|
||||
public string? ZelleReferenceCode { get; set; }
|
||||
public string? PayPalTransactionId { get; set; }
|
||||
public bool IsAnonymous { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace ROLAC.API.DTOs.Giving;
|
||||
|
||||
public class OfferingGivingLineRequest
|
||||
{
|
||||
public int? MemberId { get; set; }
|
||||
[Required] public int GivingCategoryId { get; set; }
|
||||
[Range(0.01, 9999999)] public decimal Amount { get; set; }
|
||||
[Required, MaxLength(20)] public string PaymentMethod { get; set; } = "Cash";
|
||||
[MaxLength(50)] public string? CheckNumber { get; set; }
|
||||
[MaxLength(100)] public string? ZelleReferenceCode { get; set; }
|
||||
[MaxLength(100)] public string? PayPalTransactionId { get; set; }
|
||||
public bool IsAnonymous { get; set; }
|
||||
[MaxLength(500)] public string? Notes { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace ROLAC.API.DTOs.Giving;
|
||||
|
||||
public class OfferingSessionDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public DateOnly SessionDate{ get; set; }
|
||||
public string Status { get; set; } = "";
|
||||
public decimal CashTotal { get; set; }
|
||||
public decimal CheckTotal { get; set; }
|
||||
public decimal SystemTotal { get; set; }
|
||||
public decimal Difference { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool HasProof { get; set; }
|
||||
public List<OfferingGivingLineDto> Givings { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ROLAC.API.DTOs.Giving;
|
||||
|
||||
public class OfferingSessionListItemDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string SessionDate { get; set; } = ""; // yyyy-MM-dd
|
||||
public string Status { get; set; } = "";
|
||||
public decimal CashTotal { get; set; }
|
||||
public decimal CheckTotal { get; set; }
|
||||
public decimal SystemTotal { get; set; }
|
||||
public decimal Difference { get; set; }
|
||||
public int LineCount { get; set; }
|
||||
public bool HasProof { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace ROLAC.API.DTOs.Giving;
|
||||
|
||||
// Minimal member fields the mobile offering-entry page collects when a giver
|
||||
// isn't on file yet. Creates a Visitor; the rest of the profile can be filled
|
||||
// in later from the admin Members page.
|
||||
public class QuickAddMemberRequest
|
||||
{
|
||||
[Required, MaxLength(100)] public string FirstName_en { get; set; } = "";
|
||||
[Required, MaxLength(100)] public string LastName_en { get; set; } = "";
|
||||
[MaxLength(100)] public string? NickName { get; set; }
|
||||
[MaxLength(100)] public string? FirstName_zh { get; set; }
|
||||
[MaxLength(100)] public string? LastName_zh { get; set; }
|
||||
[MaxLength(30)] public string? PhoneCell { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace ROLAC.API.DTOs.Giving;
|
||||
|
||||
public class UpdateGivingCategoryRequest
|
||||
{
|
||||
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
||||
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||
[MaxLength(500)] public string? Description_en { get; set; }
|
||||
[MaxLength(500)] public string? Description_zh { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ROLAC.API.DTOs.Giving;
|
||||
|
||||
public class UpdateGivingRequest : CreateGivingRequest { }
|
||||
@@ -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,10 @@
|
||||
namespace ROLAC.API.DTOs.MealAttendance;
|
||||
|
||||
/// <summary>The current head-count for one Sunday, broadcast over SignalR.</summary>
|
||||
public class AttendanceCountsDto
|
||||
{
|
||||
public string Date { get; set; } = ""; // yyyy-MM-dd (local)
|
||||
public int Adult { get; set; }
|
||||
public int Youth { get; set; }
|
||||
public int Kid { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace ROLAC.API.DTOs.Members;
|
||||
|
||||
public class CreateMemberRequest
|
||||
{
|
||||
[Required, MaxLength(100)] public string FirstName_en { get; set; } = "";
|
||||
[Required, MaxLength(100)] public string LastName_en { get; set; } = "";
|
||||
[MaxLength(100)] public string? NickName { get; set; }
|
||||
[MaxLength(100)] public string? FirstName_zh { get; set; }
|
||||
[MaxLength(100)] public string? LastName_zh { get; set; }
|
||||
[MaxLength(10)] public string? Gender { get; set; }
|
||||
public DateOnly? DateOfBirth { get; set; }
|
||||
public DateOnly? BaptismDate { get; set; }
|
||||
[MaxLength(200)] public string? BaptismChurch { get; set; }
|
||||
[MaxLength(200), EmailAddress] public string? Email { get; set; }
|
||||
[MaxLength(30)] public string? PhoneCell { get; set; }
|
||||
[MaxLength(30)] public string? PhoneHome { get; set; }
|
||||
[MaxLength(500)] public string? Address { get; set; }
|
||||
[MaxLength(100)] public string? City { get; set; }
|
||||
[MaxLength(50)] public string? State { get; set; }
|
||||
[MaxLength(20)] public string? ZipCode { get; set; }
|
||||
[MaxLength(100)] public string Country { get; set; } = "USA";
|
||||
[MaxLength(20)] public string Status { get; set; } = "Member";
|
||||
[MaxLength(10)] public string LanguagePreference { get; set; } = "en";
|
||||
public DateOnly? JoinDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int? FamilyUnitId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ROLAC.API.DTOs.Members;
|
||||
|
||||
public class MemberDto : MemberListItemDto
|
||||
{
|
||||
public string? Gender { get; set; }
|
||||
public DateOnly? DateOfBirth { get; set; }
|
||||
public DateOnly? BaptismDate { get; set; }
|
||||
public string? BaptismChurch { get; set; }
|
||||
public string? PhoneHome { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? ZipCode { get; set; }
|
||||
public string Country { get; set; } = "USA";
|
||||
public string? PhotoBlobPath { get; set; }
|
||||
public string LanguagePreference { get; set; } = "en";
|
||||
public string? Notes { get; set; }
|
||||
public int? FamilyUnitId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace ROLAC.API.DTOs.Members;
|
||||
|
||||
public class MemberListItemDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string FirstName_en { get; set; } = "";
|
||||
public string LastName_en { get; set; } = "";
|
||||
public string? NickName { get; set; }
|
||||
public string? FirstName_zh { get; set; }
|
||||
public string? LastName_zh { get; set; }
|
||||
public string Status { get; set; } = "";
|
||||
public string? Email { get; set; }
|
||||
public string? PhoneCell { get; set; }
|
||||
public DateOnly? JoinDate { get; set; }
|
||||
public string? LinkedUserId { get; set; } // null = no user account
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
namespace ROLAC.API.DTOs.Members;
|
||||
public class UpdateMemberRequest : CreateMemberRequest { }
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ROLAC.API.DTOs.Ministry;
|
||||
|
||||
public class MinistryDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name_en { get; set; } = "";
|
||||
public string? Name_zh { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { 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; } = [];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ROLAC.API.DTOs.Shared;
|
||||
|
||||
public class PagedResult<T>
|
||||
{
|
||||
public List<T> Items { get; set; } = [];
|
||||
public int TotalCount { get; set; }
|
||||
public int Page { get; set; }
|
||||
public int PageSize { get; set; }
|
||||
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace ROLAC.API.DTOs.Users;
|
||||
|
||||
public class CreateUserRequest
|
||||
{
|
||||
public int? MemberId { get; set; }
|
||||
[Required, EmailAddress] public string Email { get; set; } = "";
|
||||
[Required, MinLength(1)] public List<string> Roles { get; set; } = [];
|
||||
public string LanguagePreference { get; set; } = "en";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ROLAC.API.DTOs.Users;
|
||||
|
||||
public class CreateUserResult
|
||||
{
|
||||
public string UserId { get; set; } = "";
|
||||
public string TempPassword { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace ROLAC.API.DTOs.Users;
|
||||
|
||||
public class UpdateUserRequest
|
||||
{
|
||||
[Required, EmailAddress] public string Email { get; set; } = "";
|
||||
[Required] public List<string> Roles { get; set; } = [];
|
||||
public bool IsActive { get; set; }
|
||||
public string LanguagePreference { get; set; } = "en";
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
namespace ROLAC.API.DTOs.Users;
|
||||
public class UserDto : UserListItemDto { }
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ROLAC.API.DTOs.Users;
|
||||
|
||||
public class UserListItemDto
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Email { get; set; } = "";
|
||||
public int? MemberId { get; set; }
|
||||
public string? MemberDisplayName { get; set; }
|
||||
public List<string> Roles { get; set; } = [];
|
||||
public bool IsActive { get; set; }
|
||||
public string LanguagePreference { get; set; } = "en";
|
||||
public DateTime? LastLoginAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Data.Logging;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Entities.Notifications;
|
||||
|
||||
namespace ROLAC.API.Data;
|
||||
|
||||
@@ -9,43 +11,375 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
public DbSet<Member> Members => Set<Member>();
|
||||
public DbSet<FamilyUnit> FamilyUnits => Set<FamilyUnit>();
|
||||
public DbSet<GivingCategory> GivingCategories => Set<GivingCategory>();
|
||||
public DbSet<OfferingSession> OfferingSessions => Set<OfferingSession>();
|
||||
public DbSet<Giving> Givings => Set<Giving>();
|
||||
public DbSet<Ministry> Ministries => Set<Ministry>();
|
||||
public DbSet<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>();
|
||||
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
|
||||
public DbSet<Expense> Expenses => Set<Expense>();
|
||||
public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>();
|
||||
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
|
||||
public DbSet<Check> Checks => Set<Check>();
|
||||
public DbSet<CheckLine> CheckLines => Set<CheckLine>();
|
||||
public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>();
|
||||
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
|
||||
|
||||
public DbSet<MemberChannelBinding> MemberChannelBindings => Set<MemberChannelBinding>();
|
||||
public DbSet<LineBindingCode> LineBindingCodes => Set<LineBindingCode>();
|
||||
public DbSet<MessagingGroup> MessagingGroups => Set<MessagingGroup>();
|
||||
public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
|
||||
// ── RefreshToken (unchanged) ────────────────────────────────────────
|
||||
builder.Entity<RefreshToken>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
// Unique index on hash — enables fast lookup and prevents duplicate tokens
|
||||
entity.HasIndex(e => e.TokenHash).IsUnique();
|
||||
|
||||
entity.Property(e => e.TokenHash).HasMaxLength(64).IsRequired();
|
||||
entity.Property(e => e.UserId).HasMaxLength(450).IsRequired();
|
||||
entity.Property(e => e.DeviceInfo).HasMaxLength(200);
|
||||
entity.Property(e => e.IpAddress).HasMaxLength(45);
|
||||
entity.Property(e => e.ReplacedByHash).HasMaxLength(64);
|
||||
|
||||
entity.HasOne(e => e.User)
|
||||
.WithMany(u => u.RefreshTokens)
|
||||
.HasForeignKey(e => e.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Computed properties are not DB columns
|
||||
entity.HasOne(e => e.User).WithMany(u => u.RefreshTokens)
|
||||
.HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.Ignore(e => e.IsExpired);
|
||||
entity.Ignore(e => e.IsRevoked);
|
||||
entity.Ignore(e => e.IsActive);
|
||||
});
|
||||
|
||||
// ── AppUser (unchanged + new unique index on MemberId) ──────────────
|
||||
builder.Entity<AppUser>(entity =>
|
||||
{
|
||||
entity.Property(e => e.LanguagePreference).HasMaxLength(10).HasDefaultValue("en");
|
||||
// Nullable unique: one member ↔ one user account, but member can have no account
|
||||
entity.HasIndex(e => e.MemberId).IsUnique()
|
||||
.HasFilter("\"MemberId\" IS NOT NULL");
|
||||
});
|
||||
|
||||
// ── AppRole (unchanged) ─────────────────────────────────────────────
|
||||
builder.Entity<AppRole>(entity =>
|
||||
{
|
||||
entity.Property(e => e.Description).HasMaxLength(500);
|
||||
});
|
||||
|
||||
// ── RolePermission (configurable RBAC matrix) ───────────────────────
|
||||
builder.Entity<RolePermission>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.RoleId).HasMaxLength(450).IsRequired();
|
||||
entity.Property(e => e.Module).HasMaxLength(60).IsRequired();
|
||||
// One row per (role, module).
|
||||
entity.HasIndex(e => new { e.RoleId, e.Module }).IsUnique();
|
||||
entity.HasOne(e => e.Role).WithMany()
|
||||
.HasForeignKey(e => e.RoleId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// ── FamilyUnit ──────────────────────────────────────────────────────
|
||||
builder.Entity<FamilyUnit>(entity =>
|
||||
{
|
||||
entity.Property(e => e.FamilyName_en).HasMaxLength(200);
|
||||
entity.Property(e => e.FamilyName_zh).HasMaxLength(200);
|
||||
});
|
||||
|
||||
// ── Member ──────────────────────────────────────────────────────────
|
||||
builder.Entity<Member>(entity =>
|
||||
{
|
||||
entity.HasQueryFilter(m => !m.IsDeleted);
|
||||
|
||||
entity.Property(e => e.FirstName_en).HasMaxLength(100).IsRequired();
|
||||
entity.Property(e => e.LastName_en).HasMaxLength(100).IsRequired();
|
||||
entity.Property(e => e.NickName).HasMaxLength(100);
|
||||
entity.Property(e => e.FirstName_zh).HasMaxLength(100);
|
||||
entity.Property(e => e.LastName_zh).HasMaxLength(100);
|
||||
entity.Property(e => e.Gender).HasMaxLength(10);
|
||||
entity.Property(e => e.BaptismChurch).HasMaxLength(200);
|
||||
entity.Property(e => e.Email).HasMaxLength(200);
|
||||
entity.Property(e => e.PhoneCell).HasMaxLength(30);
|
||||
entity.Property(e => e.PhoneHome).HasMaxLength(30);
|
||||
entity.Property(e => e.Address).HasMaxLength(500);
|
||||
entity.Property(e => e.City).HasMaxLength(100);
|
||||
entity.Property(e => e.State).HasMaxLength(50);
|
||||
entity.Property(e => e.ZipCode).HasMaxLength(20);
|
||||
entity.Property(e => e.Country).HasMaxLength(100).HasDefaultValue("USA");
|
||||
entity.Property(e => e.PhotoBlobPath).HasMaxLength(500);
|
||||
entity.Property(e => e.Status).HasMaxLength(20).HasDefaultValue("Member");
|
||||
entity.Property(e => e.LanguagePreference).HasMaxLength(10).HasDefaultValue("en");
|
||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.DeletedBy).HasMaxLength(450);
|
||||
|
||||
entity.HasIndex(e => e.Status).HasFilter("\"IsDeleted\" = false");
|
||||
entity.HasIndex(e => e.Email).HasFilter("\"Email\" IS NOT NULL");
|
||||
entity.HasIndex(e => e.FamilyUnitId);
|
||||
|
||||
entity.HasOne(e => e.FamilyUnit).WithMany()
|
||||
.HasForeignKey(e => e.FamilyUnitId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// ── GivingCategory ───────────────────────────────────────────────────
|
||||
builder.Entity<GivingCategory>(entity =>
|
||||
{
|
||||
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
|
||||
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
||||
entity.Property(e => e.Description_en).HasMaxLength(500);
|
||||
entity.Property(e => e.Description_zh).HasMaxLength(500);
|
||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||
});
|
||||
|
||||
// ── OfferingSession ──────────────────────────────────────────────────
|
||||
builder.Entity<OfferingSession>(entity =>
|
||||
{
|
||||
entity.Property(e => e.Status).HasMaxLength(20).HasDefaultValue("Draft");
|
||||
entity.Property(e => e.CashTotal).HasColumnType("decimal(18,2)");
|
||||
entity.Property(e => e.CheckTotal).HasColumnType("decimal(18,2)");
|
||||
entity.Property(e => e.SystemTotal).HasColumnType("decimal(18,2)");
|
||||
entity.Property(e => e.Difference).HasColumnType("decimal(18,2)");
|
||||
entity.Property(e => e.SubmittedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.ReconciledBy).HasMaxLength(450);
|
||||
entity.Property(e => e.ProofPdfPath).HasMaxLength(500);
|
||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||
entity.HasIndex(e => e.SessionDate).IsUnique();
|
||||
});
|
||||
|
||||
// ── Giving ───────────────────────────────────────────────────────────
|
||||
builder.Entity<Giving>(entity =>
|
||||
{
|
||||
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
|
||||
entity.Property(e => e.PaymentMethod).HasMaxLength(20).IsRequired();
|
||||
entity.Property(e => e.CheckNumber).HasMaxLength(50);
|
||||
entity.Property(e => e.ZelleReferenceCode).HasMaxLength(100);
|
||||
entity.Property(e => e.PayPalTransactionId).HasMaxLength(100);
|
||||
entity.Property(e => e.Notes).HasMaxLength(500);
|
||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||
|
||||
entity.HasIndex(e => new { e.MemberId, e.GivingDate });
|
||||
entity.HasIndex(e => e.OfferingSessionId).HasFilter("\"OfferingSessionId\" IS NOT NULL");
|
||||
entity.HasIndex(e => e.GivingDate);
|
||||
|
||||
entity.HasOne(e => e.GivingCategory).WithMany()
|
||||
.HasForeignKey(e => e.GivingCategoryId).OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(e => e.Member).WithMany()
|
||||
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
|
||||
entity.HasOne(e => e.OfferingSession).WithMany(s => s.Givings)
|
||||
.HasForeignKey(e => e.OfferingSessionId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// ── Ministry ─────────────────────────────────────────────────────────
|
||||
builder.Entity<Ministry>(entity =>
|
||||
{
|
||||
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
|
||||
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
||||
});
|
||||
|
||||
// ── ExpenseCategoryGroup ─────────────────────────────────────────────
|
||||
builder.Entity<ExpenseCategoryGroup>(entity =>
|
||||
{
|
||||
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
|
||||
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||
});
|
||||
|
||||
// ── ExpenseSubCategory ───────────────────────────────────────────────
|
||||
builder.Entity<ExpenseSubCategory>(entity =>
|
||||
{
|
||||
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
|
||||
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||
entity.HasOne(e => e.Group).WithMany(g => g.SubCategories)
|
||||
.HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// ── Expense ──────────────────────────────────────────────────────────
|
||||
builder.Entity<Expense>(entity =>
|
||||
{
|
||||
entity.HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
entity.Property(e => e.Type).HasMaxLength(30).IsRequired();
|
||||
entity.Property(e => e.Status).HasMaxLength(30).HasDefaultValue("Draft");
|
||||
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
|
||||
entity.Property(e => e.Description).HasMaxLength(500).IsRequired();
|
||||
entity.Property(e => e.VendorName).HasMaxLength(200);
|
||||
entity.Property(e => e.CheckNumber).HasMaxLength(50);
|
||||
entity.Property(e => e.ReceiptBlobPath).HasMaxLength(500);
|
||||
entity.Property(e => e.ReviewNotes).HasMaxLength(500);
|
||||
entity.Property(e => e.SubmittedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.ReviewedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.PaidBy).HasMaxLength(450);
|
||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.DeletedBy).HasMaxLength(450);
|
||||
|
||||
entity.HasIndex(e => e.MinistryId);
|
||||
entity.HasIndex(e => e.Status).HasFilter("\"IsDeleted\" = false");
|
||||
entity.HasIndex(e => e.ExpenseDate);
|
||||
|
||||
entity.HasOne(e => e.Ministry).WithMany()
|
||||
.HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(e => e.CategoryGroup).WithMany()
|
||||
.HasForeignKey(e => e.CategoryGroupId).OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(e => e.SubCategory).WithMany()
|
||||
.HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(e => e.Member).WithMany()
|
||||
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// ── ChurchProfile (singleton settings) ───────────────────────────────
|
||||
builder.Entity<ChurchProfile>(entity =>
|
||||
{
|
||||
entity.Property(e => e.Name).HasMaxLength(200).IsRequired();
|
||||
entity.Property(e => e.Address).HasMaxLength(500);
|
||||
entity.Property(e => e.City).HasMaxLength(100);
|
||||
entity.Property(e => e.State).HasMaxLength(50);
|
||||
entity.Property(e => e.ZipCode).HasMaxLength(20);
|
||||
entity.Property(e => e.BankName).HasMaxLength(200);
|
||||
entity.Property(e => e.BankAccountNumber).HasMaxLength(50);
|
||||
entity.Property(e => e.BankRoutingNumber).HasMaxLength(50);
|
||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||
// Optimistic-concurrency token for safe check-number allocation.
|
||||
entity.Property(e => e.xmin).IsRowVersion();
|
||||
});
|
||||
|
||||
// ── Check (disbursement) ─────────────────────────────────────────────
|
||||
builder.Entity<Check>(entity =>
|
||||
{
|
||||
entity.HasQueryFilter(c => !c.IsDeleted);
|
||||
|
||||
entity.Property(e => e.CheckNumber).HasMaxLength(50).IsRequired();
|
||||
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
|
||||
entity.Property(e => e.PayeeType).HasMaxLength(20).IsRequired();
|
||||
entity.Property(e => e.PayeeName).HasMaxLength(200).IsRequired();
|
||||
entity.Property(e => e.PayeeAddress).HasMaxLength(500);
|
||||
entity.Property(e => e.PayeeCity).HasMaxLength(100);
|
||||
entity.Property(e => e.PayeeState).HasMaxLength(50);
|
||||
entity.Property(e => e.PayeeZip).HasMaxLength(20);
|
||||
entity.Property(e => e.Status).HasMaxLength(20).HasDefaultValue("Issued");
|
||||
entity.Property(e => e.Memo).HasMaxLength(500);
|
||||
entity.Property(e => e.IssuedBy).HasMaxLength(450).IsRequired();
|
||||
entity.Property(e => e.VoidReason).HasMaxLength(500);
|
||||
entity.Property(e => e.VoidedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.ReceiptSignatureBlobPath).HasMaxLength(500);
|
||||
entity.Property(e => e.ReceiptSignedName).HasMaxLength(200);
|
||||
entity.Property(e => e.ReceiptCapturedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.DeletedBy).HasMaxLength(450);
|
||||
|
||||
// Unique check number among non-deleted rows.
|
||||
entity.HasIndex(e => e.CheckNumber).IsUnique().HasFilter("\"IsDeleted\" = false");
|
||||
entity.HasIndex(e => e.Status).HasFilter("\"IsDeleted\" = false");
|
||||
entity.HasIndex(e => e.CheckDate);
|
||||
|
||||
entity.HasOne(e => e.Member).WithMany()
|
||||
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// ── CheckLine ────────────────────────────────────────────────────────
|
||||
builder.Entity<CheckLine>(entity =>
|
||||
{
|
||||
// Mirror the parent Check's soft-delete filter (required relationship).
|
||||
entity.HasQueryFilter(l => !l.Check!.IsDeleted);
|
||||
|
||||
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
|
||||
entity.Property(e => e.Description).HasMaxLength(500).IsRequired();
|
||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||
|
||||
entity.HasIndex(e => e.CheckId);
|
||||
entity.HasIndex(e => e.ExpenseId);
|
||||
|
||||
entity.HasOne(e => e.Check).WithMany(c => c.Lines)
|
||||
.HasForeignKey(e => e.CheckId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(e => e.Expense).WithMany()
|
||||
.HasForeignKey(e => e.ExpenseId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// ── MealAttendance (one shared row per Sunday) ───────────────────────
|
||||
builder.Entity<MealAttendance>(entity =>
|
||||
{
|
||||
entity.Property(e => e.AdultCount).HasDefaultValue(0);
|
||||
entity.Property(e => e.YouthCount).HasDefaultValue(0);
|
||||
entity.Property(e => e.KidCount).HasDefaultValue(0);
|
||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||
entity.HasIndex(e => e.AttendanceDate).IsUnique();
|
||||
});
|
||||
|
||||
// ── MonthlyStatement ─────────────────────────────────────────────────
|
||||
builder.Entity<MonthlyStatement>(entity =>
|
||||
{
|
||||
entity.Property(e => e.OpeningBalance).HasColumnType("decimal(18,2)");
|
||||
entity.Property(e => e.TotalGiving).HasColumnType("decimal(18,2)");
|
||||
entity.Property(e => e.TotalOtherIncome).HasColumnType("decimal(18,2)");
|
||||
entity.Property(e => e.TotalExpenses).HasColumnType("decimal(18,2)");
|
||||
entity.Property(e => e.CalculatedClosingBalance).HasColumnType("decimal(18,2)");
|
||||
entity.Property(e => e.BankStatementBalance).HasColumnType("decimal(18,2)");
|
||||
entity.Property(e => e.Difference).HasColumnType("decimal(18,2)");
|
||||
entity.Property(e => e.FinalizedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||
entity.HasIndex(e => new { e.Year, e.Month }).IsUnique();
|
||||
});
|
||||
|
||||
// ── Notifications (email + Line) ─────────────────────────────────────
|
||||
builder.Entity<MemberChannelBinding>(entity =>
|
||||
{
|
||||
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
|
||||
entity.Property(e => e.ExternalId).HasMaxLength(100).IsRequired();
|
||||
entity.HasIndex(e => new { e.MemberId, e.Channel }).IsUnique();
|
||||
entity.HasIndex(e => new { e.Channel, e.ExternalId }).IsUnique();
|
||||
entity.HasOne(e => e.Member).WithMany()
|
||||
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<LineBindingCode>(entity =>
|
||||
{
|
||||
entity.Property(e => e.Code).HasMaxLength(20).IsRequired();
|
||||
entity.HasIndex(e => e.Code);
|
||||
entity.HasOne(e => e.Member).WithMany()
|
||||
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<MessagingGroup>(entity =>
|
||||
{
|
||||
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
|
||||
entity.Property(e => e.ExternalId).HasMaxLength(100).IsRequired();
|
||||
entity.Property(e => e.Name).HasMaxLength(200);
|
||||
entity.HasIndex(e => new { e.Channel, e.ExternalId }).IsUnique();
|
||||
});
|
||||
|
||||
builder.Entity<NotificationLog>(entity =>
|
||||
{
|
||||
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
|
||||
entity.Property(e => e.TargetType).HasMaxLength(20).IsRequired();
|
||||
entity.Property(e => e.TargetExternalId).HasMaxLength(200).IsRequired();
|
||||
entity.Property(e => e.Subject).HasMaxLength(300);
|
||||
entity.Property(e => e.Status).HasMaxLength(20).IsRequired();
|
||||
entity.Property(e => e.SentByUserId).HasMaxLength(450).IsRequired();
|
||||
entity.HasIndex(e => e.SentAt);
|
||||
entity.HasIndex(e => e.Channel);
|
||||
entity.HasOne(e => e.Member).WithMany()
|
||||
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
|
||||
entity.HasOne(e => e.MessagingGroup).WithMany()
|
||||
.HasForeignKey(e => e.MessagingGroupId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// ── SystemLog / AuditLog (append-only) ───────────────────────────────
|
||||
// Mapped here for SCHEMA only — there are deliberately no DbSets on this
|
||||
// context, so business code can't write logs through the audited context.
|
||||
// Runtime reads/writes go through the dedicated LogDbContext. Including
|
||||
// them in the model lets the single startup migration create the tables.
|
||||
LogModelConfiguration.Configure(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,51 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Data;
|
||||
|
||||
public static class DbSeeder
|
||||
{
|
||||
private static readonly (string En, string Zh, int Sort)[] GivingCategorySeed =
|
||||
[
|
||||
("Tithe", "什一奉獻", 1),
|
||||
("General Offering", "一般奉獻", 2),
|
||||
("Special Offering", "特別奉獻", 3),
|
||||
("Building Fund", "建堂基金", 4),
|
||||
("Mission", "宣教奉獻", 5),
|
||||
];
|
||||
|
||||
private static readonly (string En, string Zh, int Sort)[] MinistrySeed =
|
||||
[
|
||||
("Administration", "行政", 1),
|
||||
("Preaching", "講道", 2),
|
||||
("Emcee", "司會", 3),
|
||||
("Worship", "敬拜", 4),
|
||||
("PPT/Media", "PPT/影音", 5),
|
||||
("Sound", "音控", 6),
|
||||
("Facility", "場地組", 7),
|
||||
("Hospitality", "招待", 8),
|
||||
("Children", "兒牧", 9),
|
||||
("Catering", "餐飲", 10),
|
||||
];
|
||||
|
||||
// (GroupEn, GroupZh, Sort, SubItems[(SubEn, SubZh)])
|
||||
private static readonly (string En, string Zh, int Sort, (string En, string Zh)[] Subs)[] ExpenseCategorySeed =
|
||||
[
|
||||
("Equipment", "設備", 1, [("Purchase","購置"),("Rental","租借"),("Maintenance & Repair","維修")]),
|
||||
("Consumables", "消耗品", 2, [("Batteries","電池"),("Accessories","配件"),("Cleaning Supplies","清潔用品"),("Office Supplies","文具")]),
|
||||
("Food & Beverage", "餐飲", 3, [("Catering","出餐費用"),("Food Ingredients","食材採購"),("Utensils","器具"),("Consumables","消耗品")]),
|
||||
("Training", "培訓", 4, [("Course Fees","課程費用"),("Books","書籍"),("Conference","研討會"),("Travel","差旅")]),
|
||||
("Materials", "教材", 5, [("Printing","印刷費用"),("Craft Supplies","手工材料"),("Copyright & Licensing","版權購買")]),
|
||||
("Facility", "場地", 6, [("Rent","場地租金"),("Utilities","水電"),("Property Insurance","財產保險"),("Decoration","裝飾")]),
|
||||
("Printing", "印刷", 7, [("Bulletins","週報"),("Order of Service","程序單"),("Posters","海報")]),
|
||||
("Missions", "宣教", 8, [("Offering Transfer","奉獻轉帳"),("Missionary Support","宣教士支援"),("Travel","差旅")]),
|
||||
("Benevolence", "關懷救助", 9, [("Emergency Aid","急難救助"),("Condolence Gifts","慰問禮品"),("Visit Expenses","探訪費用")]),
|
||||
("Other", "其他", 10, [("Miscellaneous","雜支")]),
|
||||
("Personnel", "人事", 11, [("Salary & Wages","薪資"),("Payroll Taxes","薪資稅費"),("Employee Benefits","員工福利"),("Workers Compensation","勞工保險"),("Honorarium","酬庸"),("Staff Training","同工進修"),("Contract Labor","外包勞務")]),
|
||||
];
|
||||
|
||||
private static readonly (string Name, string Description)[] Roles =
|
||||
[
|
||||
("super_admin", "System administrator — full access"),
|
||||
@@ -22,6 +63,67 @@ public static class DbSeeder
|
||||
("visitor", "Visitor — public pages only"),
|
||||
];
|
||||
|
||||
// Default permission matrix — mirrors the hard-coded [Authorize(Roles=...)] rules that
|
||||
// existed before the configurable RBAC system, so day-one behavior is unchanged.
|
||||
// super_admin is intentionally absent: it bypasses all checks (see PermissionAuthorizationHandler).
|
||||
// R=Read, W=Write, D=Delete, A=Approve. Rows are inserted only if missing, so an admin's
|
||||
// later edits via the Permissions UI are never clobbered on restart.
|
||||
private static readonly (string Role, string Module, bool R, bool W, bool D, bool A)[] RolePermissionSeed =
|
||||
[
|
||||
// Secretary — manages member data.
|
||||
("secretary", Modules.Members, true, true, true, false),
|
||||
|
||||
// Pastor — read-only overview of members and all expenses.
|
||||
("pastor", Modules.Members, true, false, false, false),
|
||||
("pastor", Modules.Expenses, true, false, false, false),
|
||||
|
||||
// Finance — full control over the finance modules.
|
||||
("finance", Modules.Givings, true, true, true, false),
|
||||
("finance", Modules.GivingCategories, true, true, true, false),
|
||||
("finance", Modules.Expenses, true, true, true, true),
|
||||
("finance", Modules.ExpenseCategories, true, true, true, false),
|
||||
("finance", Modules.OfferingSessions, true, true, true, true),
|
||||
("finance", Modules.FinanceDashboard, true, false, false, false),
|
||||
("finance", Modules.MonthlyStatements, true, true, false, true),
|
||||
("finance", Modules.ChurchProfile, true, true, false, false),
|
||||
("finance", Modules.Disbursements, true, true, true, true),
|
||||
|
||||
// Logs — read-only. System logs are technical (pastor only); audit logs have
|
||||
// governance value, so finance and board members can read them too.
|
||||
("pastor", Modules.SystemLogs, true, false, false, false),
|
||||
("pastor", Modules.AuditLogs, true, false, false, false),
|
||||
("finance", Modules.AuditLogs, true, false, false, false),
|
||||
("board_member", Modules.AuditLogs, true, false, false, false),
|
||||
];
|
||||
|
||||
public static async Task SeedRolePermissionsAsync(AppDbContext db)
|
||||
{
|
||||
var rolesByName = await db.Roles
|
||||
.Where(r => r.Name != null)
|
||||
.ToDictionaryAsync(r => r.Name!, r => r.Id);
|
||||
|
||||
foreach (var (role, module, read, write, delete, approve) in RolePermissionSeed)
|
||||
{
|
||||
if (!rolesByName.TryGetValue(role, out var roleId))
|
||||
continue;
|
||||
|
||||
var exists = await db.RolePermissions.AnyAsync(p => p.RoleId == roleId && p.Module == module);
|
||||
if (exists)
|
||||
continue; // never clobber an admin's edit
|
||||
|
||||
db.RolePermissions.Add(new RolePermission
|
||||
{
|
||||
RoleId = roleId,
|
||||
Module = module,
|
||||
CanRead = read,
|
||||
CanWrite = write,
|
||||
CanDelete = delete,
|
||||
CanApprove = approve,
|
||||
});
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public static async Task SeedRolesAsync(RoleManager<AppRole> roleManager)
|
||||
{
|
||||
foreach (var (name, description) in Roles)
|
||||
@@ -37,6 +139,75 @@ public static class DbSeeder
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task SeedGivingCategoriesAsync(AppDbContext db)
|
||||
{
|
||||
foreach (var (en, zh, sort) in GivingCategorySeed)
|
||||
{
|
||||
if (!await db.GivingCategories.AnyAsync(c => c.Name_en == en))
|
||||
{
|
||||
db.GivingCategories.Add(new GivingCategory
|
||||
{
|
||||
Name_en = en,
|
||||
Name_zh = zh,
|
||||
SortOrder = sort,
|
||||
IsActive = true,
|
||||
// Audit fields are stamped by AuditSaveChangesInterceptor on save.
|
||||
});
|
||||
}
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public static async Task SeedMinistriesAsync(AppDbContext db)
|
||||
{
|
||||
foreach (var (en, zh, sort) in MinistrySeed)
|
||||
{
|
||||
if (!await db.Ministries.AnyAsync(m => m.Name_en == en))
|
||||
db.Ministries.Add(new Ministry { Name_en = en, Name_zh = zh, SortOrder = sort, IsActive = true });
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public static async Task SeedExpenseCategoriesAsync(AppDbContext db)
|
||||
{
|
||||
foreach (var (gEn, gZh, gSort, subs) in ExpenseCategorySeed)
|
||||
{
|
||||
var group = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == gEn);
|
||||
if (group is null)
|
||||
{
|
||||
group = new ExpenseCategoryGroup { Name_en = gEn, Name_zh = gZh, SortOrder = gSort, IsActive = true };
|
||||
db.ExpenseCategoryGroups.Add(group);
|
||||
await db.SaveChangesAsync(); // assign group.Id
|
||||
}
|
||||
|
||||
var sub = 1;
|
||||
foreach (var (sEn, sZh) in subs)
|
||||
{
|
||||
if (!await db.ExpenseSubCategories.AnyAsync(s => s.GroupId == group.Id && s.Name_en == sEn))
|
||||
db.ExpenseSubCategories.Add(new ExpenseSubCategory
|
||||
{ GroupId = group.Id, Name_en = sEn, Name_zh = sZh, SortOrder = sub, IsActive = true });
|
||||
sub++;
|
||||
}
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public static async Task SeedChurchProfileAsync(AppDbContext db)
|
||||
{
|
||||
// Singleton row used by the disbursement module (issuer info + check counter).
|
||||
if (!await db.ChurchProfiles.AnyAsync())
|
||||
{
|
||||
db.ChurchProfiles.Add(new ChurchProfile
|
||||
{
|
||||
Name = "River Of Life Christian Church",
|
||||
City = "Arcadia",
|
||||
State = "CA",
|
||||
NextCheckNumber = 1001,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds roles and (in Development) the default admin account.
|
||||
/// Called once on application startup after migrations have been applied.
|
||||
@@ -49,6 +220,13 @@ public static class DbSeeder
|
||||
|
||||
await SeedRolesAsync(roleManager);
|
||||
|
||||
var db = services.GetRequiredService<AppDbContext>();
|
||||
await SeedRolePermissionsAsync(db);
|
||||
await SeedGivingCategoriesAsync(db);
|
||||
await SeedMinistriesAsync(db);
|
||||
await SeedExpenseCategoriesAsync(db);
|
||||
await SeedChurchProfileAsync(db);
|
||||
|
||||
if (env.IsDevelopment())
|
||||
await SeedAdminUserAsync(userManager);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using ROLAC.API.Entities.Base;
|
||||
using ROLAC.API.Services.Logging;
|
||||
|
||||
namespace ROLAC.API.Data.Interceptors;
|
||||
|
||||
public class AuditSaveChangesInterceptor : SaveChangesInterceptor
|
||||
{
|
||||
private readonly CurrentUserAccessor _currentUser;
|
||||
|
||||
public AuditSaveChangesInterceptor(CurrentUserAccessor currentUser) => _currentUser = currentUser;
|
||||
|
||||
public override InterceptionResult<int> SavingChanges(
|
||||
DbContextEventData eventData, InterceptionResult<int> result)
|
||||
{
|
||||
Stamp(eventData.Context);
|
||||
return base.SavingChanges(eventData, result);
|
||||
}
|
||||
|
||||
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
|
||||
DbContextEventData eventData, InterceptionResult<int> result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Stamp(eventData.Context);
|
||||
return base.SavingChangesAsync(eventData, result, cancellationToken);
|
||||
}
|
||||
|
||||
private void Stamp(DbContext? db)
|
||||
{
|
||||
if (db is null) return;
|
||||
|
||||
var userId = _currentUser.UserIdOrSystem;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
foreach (var entry in db.ChangeTracker.Entries())
|
||||
{
|
||||
if (entry.Entity is not AuditableEntity audit) continue;
|
||||
|
||||
if (entry.State == EntityState.Added)
|
||||
{
|
||||
audit.CreatedAt = now;
|
||||
audit.CreatedBy = userId;
|
||||
audit.UpdatedAt = now;
|
||||
audit.UpdatedBy = userId;
|
||||
}
|
||||
else if (entry.State == EntityState.Modified)
|
||||
{
|
||||
audit.UpdatedAt = now;
|
||||
audit.UpdatedBy = userId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user