Compare commits
166 Commits
ddced87dc6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b949dff9b | |||
| 773d38d838 | |||
| d987ddea0e | |||
| a4ded78442 | |||
| 831b868d9d | |||
| 771889a99a | |||
| 4d396601f7 | |||
| d29de83116 | |||
| ad276c01f3 | |||
| fb95bf0048 | |||
| d8e6f3ed61 | |||
| 402826ee3d | |||
| 82096e7e6f | |||
| 6ffaaf37ac | |||
| d1747b510e | |||
| bf247726e1 | |||
| 8cb6245560 | |||
| b7eb95056d | |||
| 556abba687 | |||
| 1a8002015a | |||
| 7c63f6c9ba | |||
| 7c5348969b | |||
| 0a9b82544d | |||
| 6080946e74 | |||
| 560fb79bf0 | |||
| 0767a3fe94 | |||
| 0754ed8d69 | |||
| 9aa64b5f4c | |||
| 5e2fbe800c | |||
| 89238bba99 | |||
| 225e64b992 | |||
| 7809ba9741 | |||
| 48ae014def | |||
| 89f02d020b | |||
| 3b76ff43fc | |||
| a0b96b056a | |||
| 93374c3c0a | |||
| 55543af5e1 | |||
| d32eea3523 | |||
| 099303995b | |||
| 44a7dcf089 | |||
| a8f5547c3c | |||
| 41dce076d6 | |||
| 315d85ddcc | |||
| bc827e8b60 | |||
| 8922bb69de | |||
| 4877fec1da | |||
| 73c52ded88 | |||
| f1de8d7ab7 | |||
| 5957d0f45e | |||
| c5405a95c3 | |||
| 5d03e42302 | |||
| d4c20df34f | |||
| 73077295a4 | |||
| c5b1a9372a | |||
| ece2676e38 | |||
| 26259c252d | |||
| 120240ad0c | |||
| ece9938bfb | |||
| a16e21dbfd | |||
| 75905e7036 | |||
| bcaa3e2f25 | |||
| 5448a9ff85 | |||
| bdccb79029 | |||
| a89e936f4d | |||
| fa3e75a333 | |||
| 8bdb942a49 | |||
| 609ce6a439 | |||
| 46a4298a71 | |||
| 9f91683633 | |||
| 5aaac3246d | |||
| 677cb8f054 | |||
| f79dab163d | |||
| 4438c351e2 | |||
| 1a03a1cbba | |||
| 3f61e9ceaf | |||
| b41297f972 | |||
| a5de2dbbb1 | |||
| 1fa36ae62f | |||
| 1353b5571f | |||
| 4e83f27703 | |||
| d5e1732505 | |||
| ae757bee3d | |||
| 6e04b64466 | |||
| f70a7b5a58 | |||
| b6b110254a | |||
| d3e6b5aed5 | |||
| ac84097254 | |||
| 971bf165cc | |||
| f1faa0d435 | |||
| 9dbb1d38d8 | |||
| e908e35530 | |||
| b51f22cfba | |||
| 764464e785 | |||
| cfd344f48c | |||
| 4dc7ff7df7 | |||
| e9aad74df6 | |||
| e768f53ccc | |||
| b0e2e112fc | |||
| 28eba8a3ea | |||
| 7eb6a4db78 | |||
| 7dc03f3bc0 | |||
| 8d91bbeb31 | |||
| 182f8bf74c | |||
| a88567fea6 | |||
| e53cea7a82 | |||
| e88ea7917f | |||
| 99585a1c0e | |||
| d327a5146c | |||
| 4276ca890b | |||
| 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 |
@@ -1,72 +0,0 @@
|
|||||||
name: ci-cd-nas
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# Runs on the DEV PC runner (label `builder`): Docker Desktop + .NET SDK.
|
|
||||||
# DS220+ (Celeron J4025 / 2GB RAM) cannot build these images, so all the heavy
|
|
||||||
# work (test, dotnet publish, ng build) happens here, then images are pushed
|
|
||||||
# to the Gitea registry on the NAS.
|
|
||||||
build-push:
|
|
||||||
# Label is registered on the dev PC as `windows:host`; runs-on matches the
|
|
||||||
# label NAME (before the colon). `:host` means it runs directly on the PC,
|
|
||||||
# using its installed Docker Desktop + .NET SDK (no container).
|
|
||||||
runs-on: windows
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
# Git Bash (bundled with Git for Windows) — needed for `$REGISTRY` and
|
|
||||||
# the heredoc-style multi-line steps below.
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
REGISTRY: git.golife.love/chrischen
|
|
||||||
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 -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"
|
|
||||||
|
|
||||||
# Runs on the NAS runner (label `nas`): host Docker socket mounted and
|
|
||||||
# /volume1/docker/rolac bind-mounted at the same path. Deploy ONLY — it just
|
|
||||||
# pulls the freshly-built images and (re)starts the stack. No building here.
|
|
||||||
deploy:
|
|
||||||
needs: build-push
|
|
||||||
runs-on: nas
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
DEPLOY_DIR: /volume1/docker/rolac
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Registry login
|
|
||||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.golife.love -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
|
||||||
|
|
||||||
- name: Sync compose + nginx to deploy dir
|
|
||||||
run: |
|
|
||||||
mkdir -p "$DEPLOY_DIR/nginx/conf.d" "$DEPLOY_DIR/data/api-storage"
|
|
||||||
cp deploy/nas/docker-compose.yml "$DEPLOY_DIR/docker-compose.yml"
|
|
||||||
cp deploy/nas/nginx/conf.d/rolac.conf "$DEPLOY_DIR/nginx/conf.d/rolac.conf"
|
|
||||||
|
|
||||||
- name: Deploy
|
|
||||||
run: |
|
|
||||||
cd "$DEPLOY_DIR"
|
|
||||||
export TAG=${{ github.sha }}
|
|
||||||
docker compose pull
|
|
||||||
docker compose up -d
|
|
||||||
sleep 5
|
|
||||||
curl -fsS http://localhost:8080/api/health
|
|
||||||
@@ -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
|
||||||
@@ -51,4 +51,42 @@ jobs:
|
|||||||
export TAG=${{ github.sha }}
|
export TAG=${{ github.sha }}
|
||||||
docker compose pull
|
docker compose pull
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
curl -fsS https://app.rolac.org/api/health
|
curl -fsS https://manage.rolac.org/api/health
|
||||||
|
|
||||||
|
# Always runs (success or failure) so the team gets a build result in Rocket.Chat.
|
||||||
|
# A failed or skipped upstream job (skipped means an earlier job failed) reports as failed.
|
||||||
|
notify:
|
||||||
|
needs: [test, build-push, deploy]
|
||||||
|
if: always()
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Notify Rocket.Chat
|
||||||
|
env:
|
||||||
|
BUILD_FAILED: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
REF: ${{ github.ref_name }}
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
ACTOR: ${{ github.actor }}
|
||||||
|
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}
|
||||||
|
WEBHOOK: ${{ secrets.ROCKETCHAT_WEBHOOK }}
|
||||||
|
run: |
|
||||||
|
if [ "$BUILD_FAILED" = "true" ]; then
|
||||||
|
STATUS_TEXT="❌ Build failed"
|
||||||
|
COLOR="#e74c3c"
|
||||||
|
else
|
||||||
|
STATUS_TEXT="✅ Build succeeded"
|
||||||
|
COLOR="#2ecc71"
|
||||||
|
fi
|
||||||
|
SHORT_SHA="${SHA:0:7}"
|
||||||
|
curl -fsS -X POST -H 'Content-Type: application/json' --data @- "$WEBHOOK" <<JSON
|
||||||
|
{
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"title": "$REPO — $STATUS_TEXT",
|
||||||
|
"title_link": "$COMMIT_URL",
|
||||||
|
"color": "$COLOR",
|
||||||
|
"text": "Branch *$REF* · commit $SHORT_SHA · by $ACTOR"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|||||||
+13
-3
@@ -1,10 +1,13 @@
|
|||||||
# ---- build ----
|
# ---- build ----
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||||
WORKDIR /src
|
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/
|
COPY ROLAC.API/ROLAC.API.csproj ROLAC.API/
|
||||||
RUN dotnet restore ROLAC.API/ROLAC.API.csproj
|
RUN dotnet restore ROLAC.API/ROLAC.API.csproj --configfile nuget.config
|
||||||
COPY ROLAC.API/ ROLAC.API/
|
COPY ROLAC.API/ ROLAC.API/
|
||||||
RUN dotnet publish ROLAC.API/ROLAC.API.csproj -c Release -o /app/publish /p:UseAppHost=false
|
RUN dotnet publish ROLAC.API/ROLAC.API.csproj -c Release -o /app/publish /p:UseAppHost=false --no-restore
|
||||||
|
|
||||||
# ---- runtime ----
|
# ---- runtime ----
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
|
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
|
||||||
@@ -12,8 +15,15 @@ WORKDIR /app
|
|||||||
ENV ASPNETCORE_ENVIRONMENT=Production \
|
ENV ASPNETCORE_ENVIRONMENT=Production \
|
||||||
ASPNETCORE_HTTP_PORTS=8080
|
ASPNETCORE_HTTP_PORTS=8080
|
||||||
# curl: used by the HEALTHCHECK (not present in the base image)
|
# 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 \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends curl \
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
libfontconfig1 \
|
||||||
|
fontconfig \
|
||||||
|
fonts-dejavu \
|
||||||
|
fonts-noto-cjk \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=build /app/publish .
|
COPY --from=build /app/publish .
|
||||||
# storage dir created + owned for the non-root app user
|
# storage dir created + owned for the non-root app user
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Moq;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Authorization;
|
||||||
|
|
||||||
|
public class PermissionAuthorizationHandlerTests
|
||||||
|
{
|
||||||
|
private static ClaimsPrincipal UserWithRoles(params string[] roles)
|
||||||
|
{
|
||||||
|
var claims = roles.Select(role => new Claim("role", role));
|
||||||
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: "test"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> EvaluateAsync(
|
||||||
|
ClaimsPrincipal user, PermissionRequirement requirement, IPermissionService permissions)
|
||||||
|
{
|
||||||
|
var handler = new PermissionAuthorizationHandler(permissions);
|
||||||
|
var context = new AuthorizationHandlerContext([requirement], user, resource: null);
|
||||||
|
await handler.HandleAsync(context);
|
||||||
|
return context.HasSucceeded;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SuperAdmin_AlwaysSucceeds_WithoutConsultingMatrix()
|
||||||
|
{
|
||||||
|
var permissions = new Mock<IPermissionService>(MockBehavior.Strict); // must NOT be called
|
||||||
|
var requirement = new PermissionRequirement(Modules.Members, PermissionActions.Delete);
|
||||||
|
|
||||||
|
var succeeded = await EvaluateAsync(UserWithRoles("super_admin"), requirement, permissions.Object);
|
||||||
|
|
||||||
|
Assert.True(succeeded);
|
||||||
|
permissions.Verify(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RoleWithPermission_Succeeds()
|
||||||
|
{
|
||||||
|
var permissions = new Mock<IPermissionService>();
|
||||||
|
permissions.Setup(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), Modules.Members, PermissionActions.Write))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
var requirement = new PermissionRequirement(Modules.Members, PermissionActions.Write);
|
||||||
|
|
||||||
|
var succeeded = await EvaluateAsync(UserWithRoles("secretary"), requirement, permissions.Object);
|
||||||
|
|
||||||
|
Assert.True(succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RoleWithoutPermission_Fails()
|
||||||
|
{
|
||||||
|
var permissions = new Mock<IPermissionService>();
|
||||||
|
permissions.Setup(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(false);
|
||||||
|
var requirement = new PermissionRequirement(Modules.Givings, PermissionActions.Write);
|
||||||
|
|
||||||
|
var succeeded = await EvaluateAsync(UserWithRoles("member"), requirement, permissions.Object);
|
||||||
|
|
||||||
|
Assert.False(succeeded);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ public class AuditInterceptorTests
|
|||||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
|
||||||
var mock = new Mock<IHttpContextAccessor>();
|
var mock = new Mock<IHttpContextAccessor>();
|
||||||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
return new AuditSaveChangesInterceptor(mock.Object);
|
return new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<PackageReference Include="Moq" Version="4.20.72" />
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration;
|
|||||||
using Moq;
|
using Moq;
|
||||||
using ROLAC.API.Data;
|
using ROLAC.API.Data;
|
||||||
using ROLAC.API.DTOs.Auth;
|
using ROLAC.API.DTOs.Auth;
|
||||||
|
using ROLAC.API.DTOs.Permissions;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -33,7 +34,8 @@ public class AuthServiceTests
|
|||||||
private static Mock<UserManager<AppUser>> BuildUserManager(
|
private static Mock<UserManager<AppUser>> BuildUserManager(
|
||||||
AppUser? findResult = null,
|
AppUser? findResult = null,
|
||||||
bool passwordOk = true,
|
bool passwordOk = true,
|
||||||
IList<string>? roles = null)
|
IList<string>? roles = null,
|
||||||
|
IdentityResult? changePasswordResult = null)
|
||||||
{
|
{
|
||||||
var store = new Mock<IUserStore<AppUser>>();
|
var store = new Mock<IUserStore<AppUser>>();
|
||||||
// Remaining ctor params are all optional; Moq passes them via reflection.
|
// Remaining ctor params are all optional; Moq passes them via reflection.
|
||||||
@@ -52,6 +54,9 @@ public class AuthServiceTests
|
|||||||
.ReturnsAsync(roles ?? new List<string> { "member" });
|
.ReturnsAsync(roles ?? new List<string> { "member" });
|
||||||
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
|
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
|
||||||
.ReturnsAsync(IdentityResult.Success);
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
|
mgr.Setup(m => m.ChangePasswordAsync(
|
||||||
|
It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(changePasswordResult ?? IdentityResult.Success);
|
||||||
|
|
||||||
return mgr;
|
return mgr;
|
||||||
}
|
}
|
||||||
@@ -72,11 +77,21 @@ public class AuthServiceTests
|
|||||||
return svc;
|
return svc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>IPermissionService mock: returns an empty effective-permission map.</summary>
|
||||||
|
private static Mock<IPermissionService> BuildPermissionService()
|
||||||
|
{
|
||||||
|
var svc = new Mock<IPermissionService>();
|
||||||
|
svc.Setup(p => p.GetEffectivePermissionsAsync(It.IsAny<IEnumerable<string>>()))
|
||||||
|
.ReturnsAsync(new Dictionary<string, ModuleActions>());
|
||||||
|
return svc;
|
||||||
|
}
|
||||||
|
|
||||||
private static AuthService BuildSut(
|
private static AuthService BuildSut(
|
||||||
Mock<UserManager<AppUser>> umMock,
|
Mock<UserManager<AppUser>> umMock,
|
||||||
Mock<ITokenService> tsMock,
|
Mock<ITokenService> tsMock,
|
||||||
AppDbContext db)
|
AppDbContext db)
|
||||||
=> new(umMock.Object, tsMock.Object, db, BuildConfig());
|
=> new(umMock.Object, tsMock.Object, db, BuildPermissionService().Object,
|
||||||
|
ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance, BuildConfig());
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Login tests
|
// Login tests
|
||||||
@@ -154,6 +169,48 @@ public class AuthServiceTests
|
|||||||
um.Verify(m => m.UpdateAsync(It.Is<AppUser>(u => u.LastLoginAt != null)), Times.Once);
|
um.Verify(m => m.UpdateAsync(It.Is<AppUser>(u => u.LastLoginAt != null)), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_LinkedMember_ReturnsMemberInfo()
|
||||||
|
{
|
||||||
|
var db = BuildDb();
|
||||||
|
db.Members.Add(new Member
|
||||||
|
{
|
||||||
|
Id = 7,
|
||||||
|
NickName = "Johnny",
|
||||||
|
FirstName_en = "John",
|
||||||
|
LastName_en = "Chen",
|
||||||
|
LastName_zh = "陳",
|
||||||
|
CreatedBy = "seed",
|
||||||
|
UpdatedBy = "seed",
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true, MemberId = 7 };
|
||||||
|
var um = BuildUserManager(findResult: user);
|
||||||
|
var ts = BuildTokenService();
|
||||||
|
var sut = BuildSut(um, ts, db);
|
||||||
|
|
||||||
|
var (response, _) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });
|
||||||
|
|
||||||
|
Assert.NotNull(response.User.MemberInfo);
|
||||||
|
Assert.Equal(7, response.User.MemberInfo!.Id);
|
||||||
|
Assert.Equal("Johnny", response.User.MemberInfo.NickName);
|
||||||
|
Assert.Equal("Chen", response.User.MemberInfo.LastName_en);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_AdminOnlyAccount_ReturnsNullMemberInfo()
|
||||||
|
{
|
||||||
|
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true, MemberId = null };
|
||||||
|
var um = BuildUserManager(findResult: user);
|
||||||
|
var ts = BuildTokenService();
|
||||||
|
var sut = BuildSut(um, ts, BuildDb());
|
||||||
|
|
||||||
|
var (response, _) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });
|
||||||
|
|
||||||
|
Assert.Null(response.User.MemberInfo);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Refresh tests
|
// Refresh tests
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -255,4 +312,85 @@ public class AuthServiceTests
|
|||||||
var token = db.RefreshTokens.Single();
|
var token = db.RefreshTokens.Single();
|
||||||
Assert.NotNull(token.RevokedAt);
|
Assert.NotNull(token.RevokedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Change password tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChangePassword_ValidRequest_Succeeds()
|
||||||
|
{
|
||||||
|
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
|
||||||
|
var um = BuildUserManager(findResult: user);
|
||||||
|
var ts = BuildTokenService();
|
||||||
|
var sut = BuildSut(um, ts, BuildDb());
|
||||||
|
|
||||||
|
var result = await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", null);
|
||||||
|
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
um.Verify(m => m.ChangePasswordAsync(user, "Old1234!", "New1234!"), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChangePassword_UnknownUser_Fails()
|
||||||
|
{
|
||||||
|
var um = BuildUserManager(findResult: null);
|
||||||
|
var ts = BuildTokenService();
|
||||||
|
var sut = BuildSut(um, ts, BuildDb());
|
||||||
|
|
||||||
|
var result = await sut.ChangePasswordAsync("missing", "Old1234!", "New1234!", null);
|
||||||
|
|
||||||
|
Assert.False(result.Succeeded);
|
||||||
|
um.Verify(m => m.ChangePasswordAsync(
|
||||||
|
It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChangePassword_WrongCurrentPassword_ReturnsFailure()
|
||||||
|
{
|
||||||
|
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
|
||||||
|
var failed = IdentityResult.Failed(new IdentityError { Description = "Incorrect password." });
|
||||||
|
var um = BuildUserManager(findResult: user, changePasswordResult: failed);
|
||||||
|
var ts = BuildTokenService();
|
||||||
|
var sut = BuildSut(um, ts, BuildDb());
|
||||||
|
|
||||||
|
var result = await sut.ChangePasswordAsync("u1", "WrongOld!", "New1234!", null);
|
||||||
|
|
||||||
|
Assert.False(result.Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChangePassword_Success_RevokesOtherSessionsButKeepsCurrent()
|
||||||
|
{
|
||||||
|
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
|
||||||
|
var um = BuildUserManager(findResult: user);
|
||||||
|
var ts = BuildTokenService(); // HashToken(x) => "hash:{x}"
|
||||||
|
var db = BuildDb();
|
||||||
|
|
||||||
|
// Current session token (raw "current-raw" => "hash:current-raw")
|
||||||
|
db.RefreshTokens.Add(new RefreshToken
|
||||||
|
{
|
||||||
|
UserId = "u1",
|
||||||
|
TokenHash = "hash:current-raw",
|
||||||
|
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
||||||
|
CreatedAt = DateTime.UtcNow.AddHours(-1),
|
||||||
|
});
|
||||||
|
// Another active session on a different device
|
||||||
|
db.RefreshTokens.Add(new RefreshToken
|
||||||
|
{
|
||||||
|
UserId = "u1",
|
||||||
|
TokenHash = "hash:other-device",
|
||||||
|
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
||||||
|
CreatedAt = DateTime.UtcNow.AddHours(-2),
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var sut = BuildSut(um, ts, db);
|
||||||
|
await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", "current-raw");
|
||||||
|
|
||||||
|
var current = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:current-raw");
|
||||||
|
var other = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:other-device");
|
||||||
|
Assert.Null(current.RevokedAt); // current session preserved
|
||||||
|
Assert.NotNull(other.RevokedAt); // other session revoked
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.Services.Ai;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class ChurchAiConfigProviderTests
|
||||||
|
{
|
||||||
|
private static AppDbContext NewDb() =>
|
||||||
|
new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAsync_returns_defaults_when_no_profile_row()
|
||||||
|
{
|
||||||
|
using var db = NewDb(); // empty DB, no ChurchProfile
|
||||||
|
|
||||||
|
var cfg = await new ChurchAiConfigProvider(db).GetAsync();
|
||||||
|
|
||||||
|
Assert.Equal("Claude", cfg.Provider);
|
||||||
|
Assert.Equal("claude-haiku-4-5-20251001", cfg.ClaudeModel);
|
||||||
|
Assert.Equal("gemini-2.5-flash-lite", cfg.GeminiModel);
|
||||||
|
Assert.Null(cfg.ClaudeApiKey);
|
||||||
|
Assert.Null(cfg.GeminiApiKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
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.Logging;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class ChurchProfileServiceTests
|
||||||
|
{
|
||||||
|
// ChurchProfile is auditable, so the InMemory store rejects saves unless the
|
||||||
|
// required CreatedBy/UpdatedBy fields are populated. Wire the same audit
|
||||||
|
// interceptor the app uses so seeded entities save cleanly.
|
||||||
|
private static AppDbContext NewDb()
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })),
|
||||||
|
};
|
||||||
|
var httpContextAccessor = new Mock<IHttpContextAccessor>();
|
||||||
|
httpContextAccessor.Setup(accessor => accessor.HttpContext).Returns(httpContext);
|
||||||
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.ConfigureWarnings(warnings => warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||||
|
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(httpContextAccessor.Object)))
|
||||||
|
.Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UpdateChurchProfileRequest Req(
|
||||||
|
string provider = "Claude", string? claudeKey = null, string? geminiKey = null,
|
||||||
|
string? claudeModel = "m", string? geminiModel = "m") =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Name = "C", NextCheckNumber = 1001, AiProvider = provider,
|
||||||
|
ClaudeModel = claudeModel, GeminiModel = geminiModel,
|
||||||
|
ClaudeApiKey = claudeKey, GeminiApiKey = geminiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAsync_masks_stored_api_keys()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
db.ChurchProfiles.Add(new ChurchProfile
|
||||||
|
{
|
||||||
|
Name = "C", ClaudeApiKey = "sk-ant-abcd1234", GeminiApiKey = "AIzaXYZ9876",
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var dto = await new ChurchProfileService(db).GetAsync();
|
||||||
|
|
||||||
|
Assert.Equal("••••••1234", dto.ClaudeApiKeyMasked);
|
||||||
|
Assert.Equal("••••••9876", dto.GeminiApiKeyMasked);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_blank_key_keeps_existing()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", ClaudeApiKey = "sk-keep-0001" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await new ChurchProfileService(db).UpdateAsync(Req(claudeKey: null));
|
||||||
|
|
||||||
|
var p = await db.ChurchProfiles.FirstAsync();
|
||||||
|
Assert.Equal("sk-keep-0001", p.ClaudeApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_nonblank_key_replaces()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", ClaudeApiKey = "sk-keep-0001" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await new ChurchProfileService(db).UpdateAsync(Req(claudeKey: "sk-new-9999"));
|
||||||
|
|
||||||
|
var p = await db.ChurchProfiles.FirstAsync();
|
||||||
|
Assert.Equal("sk-new-9999", p.ClaudeApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_sets_provider_and_models()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
db.ChurchProfiles.Add(new ChurchProfile { Name = "C" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await new ChurchProfileService(db).UpdateAsync(
|
||||||
|
Req(provider: "Gemini", claudeModel: "claude-x", geminiModel: "gemini-y"));
|
||||||
|
|
||||||
|
var p = await db.ChurchProfiles.FirstAsync();
|
||||||
|
Assert.Equal("Gemini", p.AiProvider);
|
||||||
|
Assert.Equal("claude-x", p.ClaudeModel);
|
||||||
|
Assert.Equal("gemini-y", p.GeminiModel);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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 Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class DbSeederForm990Tests
|
||||||
|
{
|
||||||
|
private static AppDbContext BuildDb()
|
||||||
|
{
|
||||||
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "seed") })) };
|
||||||
|
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 SeedExpenseCategories_AddsNewGroups_RenamesDuplicates_AndIsIdempotent()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var fnb = new ExpenseCategoryGroup { Name_en = "Food & Beverage", Name_zh = "餐飲", SortOrder = 3 };
|
||||||
|
db.ExpenseCategoryGroups.Add(fnb);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { GroupId = fnb.Id, Name_en = "Consumables", Name_zh = "消耗品" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await DbSeeder.SeedExpenseCategoriesAsync(db);
|
||||||
|
await DbSeeder.SeedExpenseCategoriesAsync(db); // idempotent second run
|
||||||
|
|
||||||
|
var groups = await db.ExpenseCategoryGroups.ToListAsync();
|
||||||
|
Assert.Contains(groups, g => g.Name_en == "Professional Services");
|
||||||
|
Assert.Contains(groups, g => g.Name_en == "Information Technology");
|
||||||
|
Assert.Contains(groups, g => g.Name_en == "Finance & Banking");
|
||||||
|
|
||||||
|
var fnbSubs = await db.ExpenseSubCategories.Where(s => s.GroupId == fnb.Id).ToListAsync();
|
||||||
|
Assert.DoesNotContain(fnbSubs, s => s.Name_en == "Consumables");
|
||||||
|
Assert.Contains(fnbSubs, s => s.Name_en == "Disposable Tableware");
|
||||||
|
|
||||||
|
Assert.Single(groups, g => g.Name_en == "Professional Services");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedMinistries_SetsAdministrationToManagementGeneral_OthersProgram()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
await DbSeeder.SeedMinistriesAsync(db);
|
||||||
|
|
||||||
|
var admin = await db.Ministries.FirstAsync(m => m.Name_en == "Administration");
|
||||||
|
var worship = await db.Ministries.FirstAsync(m => m.Name_en == "Worship");
|
||||||
|
Assert.Equal("ManagementGeneral", admin.DefaultFunctionalClass);
|
||||||
|
Assert.Equal("Program", worship.DefaultFunctionalClass);
|
||||||
|
|
||||||
|
// Activity/shepherding ministries are an attribution axis only; they default to Program
|
||||||
|
// so adding them never distorts the 990 functional columns.
|
||||||
|
var cellGroups = await db.Ministries.FirstAsync(m => m.Name_en == "Cell Groups");
|
||||||
|
var specialEvents = await db.Ministries.FirstAsync(m => m.Name_en == "Special Events");
|
||||||
|
Assert.Equal("Program", cellGroups.DefaultFunctionalClass);
|
||||||
|
Assert.Equal("Program", specialEvents.DefaultFunctionalClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedForm990Lines_CreatesCatalog_AndMapsKnownSubcategories()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
await DbSeeder.SeedExpenseCategoriesAsync(db);
|
||||||
|
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
|
||||||
|
await DbSeeder.SeedForm990ExpenseLinesAsync(db); // idempotent
|
||||||
|
|
||||||
|
Assert.Equal(1, await db.Form990ExpenseLines.CountAsync(l => l.LineCode == "7"));
|
||||||
|
Assert.True(await db.Form990ExpenseLines.AnyAsync(l => l.LineCode == "24"));
|
||||||
|
|
||||||
|
var salary = await db.ExpenseSubCategories.Include(s => s.Form990Line)
|
||||||
|
.FirstAsync(s => s.Name_en == "Salary & Wages");
|
||||||
|
Assert.Equal("7", salary.Form990Line!.LineCode);
|
||||||
|
|
||||||
|
var audit = await db.ExpenseSubCategories.Include(s => s.Form990Line)
|
||||||
|
.FirstAsync(s => s.Name_en == "Accounting & Audit");
|
||||||
|
Assert.Equal("11c", audit.Form990Line!.LineCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedForm990Lines_MapsAuditCorrectedSubcategories_OffTheLine24CatchAll()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
await DbSeeder.SeedExpenseCategoriesAsync(db);
|
||||||
|
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
|
||||||
|
|
||||||
|
async Task<string> CodeOf(string subEn) =>
|
||||||
|
(await db.ExpenseSubCategories.Include(s => s.Form990Line)
|
||||||
|
.FirstAsync(s => s.Name_en == subEn)).Form990Line!.LineCode;
|
||||||
|
|
||||||
|
// Newly mapped subcategories that previously fell through to line 24.
|
||||||
|
Assert.Equal("13", await CodeOf("Bank & Processing Fees"));
|
||||||
|
Assert.Equal("13", await CodeOf("Rental"));
|
||||||
|
Assert.Equal("13", await CodeOf("Maintenance & Repair"));
|
||||||
|
Assert.Equal("13", await CodeOf("Cleaning Supplies"));
|
||||||
|
Assert.Equal("13", await CodeOf("Craft Supplies"));
|
||||||
|
// Building repairs & maintenance are part of Occupancy (line 16), not equipment (line 13).
|
||||||
|
Assert.Equal("16", await CodeOf("Repairs & Maintenance"));
|
||||||
|
// Appreciation/outreach gifts are deliberately mapped to Other (line 24), not left unmapped.
|
||||||
|
Assert.Equal("24", await CodeOf("Gifts"));
|
||||||
|
// Visitation is a travel/program cost, not a grant to an individual.
|
||||||
|
Assert.Equal("17", await CodeOf("Visit Expenses"));
|
||||||
|
// Missions support paid to individual missionaries → line 2, not line 1 (organizations).
|
||||||
|
Assert.Equal("2", await CodeOf("Missionary Support"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedForm990Lines_RemapsExistingBadMapping_ButNotAdminOverride()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
await DbSeeder.SeedExpenseCategoriesAsync(db);
|
||||||
|
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
|
||||||
|
|
||||||
|
// Simulate a database seeded by the OLD code: Visit Expenses on line 2, Missionary
|
||||||
|
// Support on line 1. Also simulate an admin who deliberately moved one elsewhere.
|
||||||
|
var lineByCode = await db.Form990ExpenseLines.ToDictionaryAsync(l => l.LineCode, l => l.Id);
|
||||||
|
var visit = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Visit Expenses");
|
||||||
|
var missionary = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Missionary Support");
|
||||||
|
var transfer = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Offering Transfer");
|
||||||
|
visit.Form990LineId = lineByCode["2"]; // old (wrong) value → should be corrected
|
||||||
|
missionary.Form990LineId = lineByCode["1"]; // old (wrong) value → should be corrected
|
||||||
|
transfer.Form990LineId = lineByCode["24"]; // admin override → must be left alone
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
|
||||||
|
|
||||||
|
await db.Entry(visit).ReloadAsync();
|
||||||
|
await db.Entry(missionary).ReloadAsync();
|
||||||
|
await db.Entry(transfer).ReloadAsync();
|
||||||
|
Assert.Equal(lineByCode["17"], visit.Form990LineId); // corrected 2 → 17
|
||||||
|
Assert.Equal(lineByCode["2"], missionary.Form990LineId); // corrected 1 → 2
|
||||||
|
Assert.Equal(lineByCode["24"], transfer.Form990LineId); // admin edit preserved
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ public class DisbursementServiceTests
|
|||||||
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||||
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options);
|
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DisbursementService SvcAs(AppDbContext db, FakeStorage fs, string userId)
|
private static DisbursementService SvcAs(AppDbContext db, FakeStorage fs, string userId)
|
||||||
@@ -57,7 +57,7 @@ public class DisbursementServiceTests
|
|||||||
var http = new Mock<IHttpContextAccessor>();
|
var http = new Mock<IHttpContextAccessor>();
|
||||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
|
||||||
http.Setup(x => x.HttpContext).Returns(ctx);
|
http.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
return new DisbursementService(db, http.Object, fs, new FakePrint());
|
return new DisbursementService(db, http.Object, fs, new FakePrint(), ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (DisbursementService svc, AppDbContext db, FakeStorage fs) Build(string userId = "fin")
|
private static (DisbursementService svc, AppDbContext db, FakeStorage fs) Build(string userId = "fin")
|
||||||
@@ -65,6 +65,8 @@ public class DisbursementServiceTests
|
|||||||
var db = BuildDb(userId);
|
var db = BuildDb(userId);
|
||||||
db.ChurchProfiles.Add(new ChurchProfile { Id = 1, Name = "ROLAC", NextCheckNumber = 1001 });
|
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.Members.Add(new Member { Id = 1, FirstName_en = "John", LastName_en = "Doe", Address = "1 Main St", City = "Arcadia", State = "CA", ZipCode = "91006" });
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Equipment" });
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Food & Beverage" });
|
||||||
db.SaveChanges();
|
db.SaveChanges();
|
||||||
var fs = new FakeStorage();
|
var fs = new FakeStorage();
|
||||||
return (SvcAs(db, fs, userId), db, fs);
|
return (SvcAs(db, fs, userId), db, fs);
|
||||||
@@ -73,8 +75,9 @@ public class DisbursementServiceTests
|
|||||||
private static Expense Approved(string type, decimal amount, int? memberId = null, string? vendor = null) => new()
|
private static Expense Approved(string type, decimal amount, int? memberId = null, string? vendor = null) => new()
|
||||||
{
|
{
|
||||||
Type = type, Status = "Approved", Amount = amount, Description = $"{type} {amount}",
|
Type = type, Status = "Approved", Amount = amount, Description = $"{type} {amount}",
|
||||||
MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, ExpenseDate = new DateOnly(2026, 6, 1),
|
MinistryId = 1, ExpenseDate = new DateOnly(2026, 6, 1),
|
||||||
MemberId = memberId, VendorName = vendor,
|
MemberId = memberId, VendorName = vendor,
|
||||||
|
Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = amount } },
|
||||||
};
|
};
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -97,6 +100,28 @@ public class DisbursementServiceTests
|
|||||||
Assert.Equal("1 Main St", member.Address);
|
Assert.Equal("1 Main St", member.Address);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GroupedWorklist_MultiCategoryExpense_ShowsMultipleLabel()
|
||||||
|
{
|
||||||
|
var (svc, db, _) = Build();
|
||||||
|
db.Expenses.Add(new Expense
|
||||||
|
{
|
||||||
|
Type = "VendorPayment", Status = "Approved", Amount = 50m, Description = "mixed invoice",
|
||||||
|
MinistryId = 1, ExpenseDate = new DateOnly(2026, 6, 1), VendorName = "Costco",
|
||||||
|
Lines =
|
||||||
|
{
|
||||||
|
new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 30m },
|
||||||
|
new ExpenseLine { CategoryGroupId = 2, SubCategoryId = 2, Amount = 20m },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var groups = await svc.GetApprovedUnpaidGroupedAsync();
|
||||||
|
|
||||||
|
var line = groups.Single(g => g.PayeeType == "Vendor").Lines.Single();
|
||||||
|
Assert.Equal("Multiple / 多類別", line.CategoryName);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Issue_CreatesOneCheckPerPayee_MarksPaid_SequentialNumbers()
|
public async Task Issue_CreatesOneCheckPerPayee_MarksPaid_SequentialNumbers()
|
||||||
{
|
{
|
||||||
@@ -203,7 +228,7 @@ public class DisbursementServiceTests
|
|||||||
var http = new Mock<IHttpContextAccessor>();
|
var http = new Mock<IHttpContextAccessor>();
|
||||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
|
||||||
http.Setup(x => x.HttpContext).Returns(ctx);
|
http.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
return (new DisbursementService(db, http.Object, fs, print), db, fs, print);
|
return (new DisbursementService(db, http.Object, fs, print, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance), db, fs, print);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.Data.Interceptors;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services.Ai;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class ExpenseAiServiceFactoryTests
|
||||||
|
{
|
||||||
|
// ChurchProfile is auditable, so the InMemory store rejects saves unless the
|
||||||
|
// required CreatedBy/UpdatedBy fields are populated. Wire the same audit
|
||||||
|
// interceptor the app uses so seeded entities save cleanly.
|
||||||
|
private static AppDbContext NewDb()
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })),
|
||||||
|
};
|
||||||
|
var httpContextAccessor = new Mock<IHttpContextAccessor>();
|
||||||
|
httpContextAccessor.Setup(accessor => accessor.HttpContext).Returns(httpContext);
|
||||||
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.ConfigureWarnings(warnings => warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||||
|
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(httpContextAccessor.Object)))
|
||||||
|
.Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExpenseAiServiceFactory Build(AppDbContext db)
|
||||||
|
{
|
||||||
|
var cfg = new ChurchAiConfigProvider(db);
|
||||||
|
var claude = new ClaudeExpenseAiService(
|
||||||
|
new HttpClient(), cfg, db, NullLogger<ClaudeExpenseAiService>.Instance);
|
||||||
|
var gemini = new GeminiExpenseAiService(
|
||||||
|
new HttpClient(), cfg, db, NullLogger<GeminiExpenseAiService>.Instance);
|
||||||
|
return new ExpenseAiServiceFactory(cfg, claude, gemini);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolves_Claude_by_default()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Claude" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var svc = await Build(db).ResolveAsync();
|
||||||
|
|
||||||
|
Assert.IsType<ClaudeExpenseAiService>(svc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolves_Gemini_when_selected()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Gemini" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var svc = await Build(db).ResolveAsync();
|
||||||
|
|
||||||
|
Assert.IsType<GeminiExpenseAiService>(svc);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.Data.Interceptors;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services.Ai;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class ExpenseCategoryAiServiceFactoryTests
|
||||||
|
{
|
||||||
|
// ChurchProfile is auditable, so the InMemory store rejects saves unless the
|
||||||
|
// required CreatedBy/UpdatedBy fields are populated. Wire the same audit
|
||||||
|
// interceptor the app uses so seeded entities save cleanly.
|
||||||
|
private static AppDbContext NewDb()
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })),
|
||||||
|
};
|
||||||
|
var httpContextAccessor = new Mock<IHttpContextAccessor>();
|
||||||
|
httpContextAccessor.Setup(accessor => accessor.HttpContext).Returns(httpContext);
|
||||||
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.ConfigureWarnings(warnings => warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||||
|
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(httpContextAccessor.Object)))
|
||||||
|
.Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExpenseCategoryAiServiceFactory Build(AppDbContext db)
|
||||||
|
{
|
||||||
|
var cfg = new ChurchAiConfigProvider(db);
|
||||||
|
var claude = new ClaudeExpenseCategoryAiService(
|
||||||
|
new HttpClient(), cfg, db, NullLogger<ClaudeExpenseCategoryAiService>.Instance);
|
||||||
|
var gemini = new GeminiExpenseCategoryAiService(
|
||||||
|
new HttpClient(), cfg, db, NullLogger<GeminiExpenseCategoryAiService>.Instance);
|
||||||
|
return new ExpenseCategoryAiServiceFactory(cfg, claude, gemini);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolves_Claude_by_default()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Claude" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var svc = await Build(db).ResolveAsync();
|
||||||
|
|
||||||
|
Assert.IsType<ClaudeExpenseCategoryAiService>(svc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolves_Gemini_when_selected()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Gemini" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var svc = await Build(db).ResolveAsync();
|
||||||
|
|
||||||
|
Assert.IsType<GeminiExpenseCategoryAiService>(svc);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ public class ExpenseCategoryServiceTests
|
|||||||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options);
|
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -58,4 +58,23 @@ public class ExpenseCategoryServiceTests
|
|||||||
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
|
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
|
||||||
svc.UpdateGroupAsync(999, new UpdateExpenseGroupRequest { Name_en = "X" }));
|
svc.UpdateGroupAsync(999, new UpdateExpenseGroupRequest { Name_en = "X" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAndGet_RoundTrips_Form990LineId()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
db.Form990ExpenseLines.Add(new ROLAC.API.Entities.Form990ExpenseLine { Id = 1, LineCode = "24", Name_en = "Other" });
|
||||||
|
db.Form990ExpenseLines.Add(new ROLAC.API.Entities.Form990ExpenseLine { Id = 7, LineCode = "7", Name_en = "Salaries" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var svc = new ExpenseCategoryService(db);
|
||||||
|
var gid = await svc.CreateGroupAsync(new CreateExpenseGroupRequest { Name_en = "Personnel", Form990LineId = 1 });
|
||||||
|
var sid = await svc.CreateSubCategoryAsync(new CreateExpenseSubCategoryRequest { GroupId = gid, Name_en = "Salary & Wages", Form990LineId = 7 });
|
||||||
|
|
||||||
|
var all = await svc.GetAllAsync(includeInactive: true);
|
||||||
|
var sub = all.Single(g => g.Id == gid).SubCategories.Single(s => s.Id == sid);
|
||||||
|
Assert.Equal(7, sub.Form990LineId);
|
||||||
|
Assert.Equal("7", sub.Form990LineCode);
|
||||||
|
Assert.Equal(1, all.Single(g => g.Id == gid).Form990LineId);
|
||||||
|
Assert.Equal("24", all.Single(g => g.Id == gid).Form990LineCode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ using ROLAC.API.Data;
|
|||||||
using ROLAC.API.Data.Interceptors;
|
using ROLAC.API.Data.Interceptors;
|
||||||
using ROLAC.API.DTOs.Expense;
|
using ROLAC.API.DTOs.Expense;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Entities.Logging;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
using ROLAC.API.Services.Storage;
|
using ROLAC.API.Services.Storage;
|
||||||
|
using ROLAC.API.Tests.TestSupport;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace ROLAC.API.Tests.Services;
|
namespace ROLAC.API.Tests.Services;
|
||||||
@@ -33,7 +36,7 @@ public class ExpenseServiceTests
|
|||||||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options);
|
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (ExpenseService svc, AppDbContext db, FakeStorage fs) Build(string userId = "u1")
|
private static (ExpenseService svc, AppDbContext db, FakeStorage fs) Build(string userId = "u1")
|
||||||
@@ -52,7 +55,15 @@ public class ExpenseServiceTests
|
|||||||
var http = new Mock<IHttpContextAccessor>();
|
var http = new Mock<IHttpContextAccessor>();
|
||||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
|
||||||
http.Setup(x => x.HttpContext).Returns(ctx);
|
http.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
return new ExpenseService(db, http.Object, fs);
|
return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExpenseService SvcAs(AppDbContext db, FakeStorage fs, string userId, IAuditLogger audit)
|
||||||
|
{
|
||||||
|
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, audit);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier),
|
// Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier),
|
||||||
@@ -62,19 +73,25 @@ public class ExpenseServiceTests
|
|||||||
var http = new Mock<IHttpContextAccessor>();
|
var http = new Mock<IHttpContextAccessor>();
|
||||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim("sub", userId) })) };
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim("sub", userId) })) };
|
||||||
http.Setup(x => x.HttpContext).Returns(ctx);
|
http.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
return new ExpenseService(db, http.Object, fs);
|
return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CreateExpenseRequest Reimb() => new()
|
private static CreateExpenseRequest Reimb() => new()
|
||||||
{
|
{
|
||||||
Type = "StaffReimbursement", MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1,
|
Type = "StaffReimbursement", MinistryId = 1,
|
||||||
Amount = 45.50m, Description = "Batteries", ExpenseDate = new DateOnly(2026, 5, 28),
|
Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 45.50m } },
|
||||||
|
Description = "Batteries", ExpenseDate = new DateOnly(2026, 5, 28),
|
||||||
};
|
};
|
||||||
|
|
||||||
private static UpdateExpenseRequest CloneToUpdate(CreateExpenseRequest r) => new()
|
private static UpdateExpenseRequest CloneToUpdate(CreateExpenseRequest r) => new()
|
||||||
{
|
{
|
||||||
Type = r.Type, MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId,
|
Type = r.Type, MinistryId = r.MinistryId,
|
||||||
SubCategoryId = r.SubCategoryId, Amount = r.Amount, Description = r.Description,
|
Lines = r.Lines.Select(l => new ExpenseLineInput
|
||||||
|
{
|
||||||
|
CategoryGroupId = l.CategoryGroupId, SubCategoryId = l.SubCategoryId,
|
||||||
|
Amount = l.Amount, FunctionalClass = l.FunctionalClass, Description = l.Description,
|
||||||
|
}).ToList(),
|
||||||
|
Description = r.Description,
|
||||||
VendorName = r.VendorName, MemberId = r.MemberId, CheckNumber = r.CheckNumber,
|
VendorName = r.VendorName, MemberId = r.MemberId, CheckNumber = r.CheckNumber,
|
||||||
ExpenseDate = r.ExpenseDate, Notes = r.Notes,
|
ExpenseDate = r.ExpenseDate, Notes = r.Notes,
|
||||||
};
|
};
|
||||||
@@ -197,6 +214,48 @@ public class ExpenseServiceTests
|
|||||||
bobSvc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false));
|
bobSvc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_OwnPendingApproval_AsNonFinance_Succeeds()
|
||||||
|
{
|
||||||
|
// After Submit a reimbursement sits in PendingApproval; the owner may still correct it.
|
||||||
|
var (svc, db, _) = Build("alice");
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.SubmitAsync(id);
|
||||||
|
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
|
||||||
|
|
||||||
|
var edit = CloneToUpdate(Reimb());
|
||||||
|
edit.Lines[0].Amount = 99.99m;
|
||||||
|
await svc.UpdateAsync(id, edit, isFinance: false);
|
||||||
|
|
||||||
|
var e = await db.Expenses.FindAsync(id);
|
||||||
|
Assert.Equal(99.99m, e!.Amount);
|
||||||
|
Assert.Equal("PendingApproval", e.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_OwnApproved_AsNonFinance_Throws()
|
||||||
|
{
|
||||||
|
// Once approved, the owner can no longer edit.
|
||||||
|
var (svc, db, fs) = Build("alice");
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.SubmitAsync(id);
|
||||||
|
await SvcAs(db, fs, "finance").ApproveAsync(id);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
svc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveReceipt_OwnPendingApproval_AsNonFinance_Succeeds()
|
||||||
|
{
|
||||||
|
var (svc, db, _) = Build("alice");
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.SubmitAsync(id);
|
||||||
|
using var input = new MemoryStream(Encoding.UTF8.GetBytes("img"));
|
||||||
|
await svc.SaveReceiptAsync(id, input, "r.jpg", isFinance: false);
|
||||||
|
Assert.NotNull(await svc.OpenReceiptAsync(id, isFinance: true));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SoftDelete_HidesFromQueries()
|
public async Task SoftDelete_HidesFromQueries()
|
||||||
{
|
{
|
||||||
@@ -206,6 +265,84 @@ public class ExpenseServiceTests
|
|||||||
Assert.Null(await db.Expenses.FirstOrDefaultAsync(e => e.Id == id));
|
Assert.Null(await db.Expenses.FirstOrDefaultAsync(e => e.Id == id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_PersistsFunctionalClass_AndGetReturnsIt()
|
||||||
|
{
|
||||||
|
var db = BuildDb("u1");
|
||||||
|
db.Ministries.Add(new ROLAC.API.Entities.Ministry { Id = 1, Name_en = "Admin" });
|
||||||
|
db.ExpenseCategoryGroups.Add(new ROLAC.API.Entities.ExpenseCategoryGroup { Id = 1, Name_en = "Other" });
|
||||||
|
db.ExpenseSubCategories.Add(new ROLAC.API.Entities.ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Misc" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var svc = SvcAs(db, new FakeStorage(), "u1");
|
||||||
|
|
||||||
|
var id = await svc.CreateAsync(new CreateExpenseRequest
|
||||||
|
{
|
||||||
|
Type = "VendorPayment", MinistryId = 1,
|
||||||
|
Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 50m, FunctionalClass = "ManagementGeneral" } },
|
||||||
|
Description = "x", ExpenseDate = new DateOnly(2026, 5, 1),
|
||||||
|
}, isFinance: true);
|
||||||
|
|
||||||
|
var dto = await svc.GetByIdAsync(id);
|
||||||
|
Assert.Equal("ManagementGeneral", dto!.Lines.Single().FunctionalClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_MultiLine_SetsHeaderTotal_AndRoundTripsLines()
|
||||||
|
{
|
||||||
|
var (svc, db, _) = Build("u1");
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Food & Beverage" });
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Snacks" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var r = new CreateExpenseRequest
|
||||||
|
{
|
||||||
|
Type = "VendorPayment", MinistryId = 1, VendorName = "Costco",
|
||||||
|
Description = "Mixed invoice", ExpenseDate = new DateOnly(2026, 5, 1),
|
||||||
|
Lines =
|
||||||
|
{
|
||||||
|
new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 30m },
|
||||||
|
new ExpenseLineInput { CategoryGroupId = 2, SubCategoryId = 2, Amount = 12.50m },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var id = await svc.CreateAsync(r, isFinance: true);
|
||||||
|
|
||||||
|
Assert.Equal(42.50m, (await db.Expenses.FindAsync(id))!.Amount);
|
||||||
|
var dto = await svc.GetByIdAsync(id);
|
||||||
|
Assert.Equal(2, dto!.Lines.Count);
|
||||||
|
Assert.Equal(42.50m, dto.Amount);
|
||||||
|
Assert.Equal(2, dto.LineCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WithNoLines_Throws()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = Build("u1");
|
||||||
|
var r = Reimb(); r.Lines.Clear();
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.CreateAsync(r, isFinance: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_ReplacesLines_AndRecomputesTotal()
|
||||||
|
{
|
||||||
|
var (svc, db, _) = Build("alice");
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Food & Beverage" });
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Snacks" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
|
||||||
|
var edit = CloneToUpdate(Reimb());
|
||||||
|
edit.Lines = new()
|
||||||
|
{
|
||||||
|
new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 10m },
|
||||||
|
new ExpenseLineInput { CategoryGroupId = 2, SubCategoryId = 2, Amount = 5m },
|
||||||
|
};
|
||||||
|
await svc.UpdateAsync(id, edit, isFinance: false);
|
||||||
|
|
||||||
|
Assert.Equal(15m, (await db.Expenses.FindAsync(id))!.Amount);
|
||||||
|
Assert.Equal(2, await db.ExpenseLines.CountAsync(l => l.ExpenseId == id));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Receipt_SaveThenOpen_RoundTrips()
|
public async Task Receipt_SaveThenOpen_RoundTrips()
|
||||||
{
|
{
|
||||||
@@ -216,4 +353,93 @@ public class ExpenseServiceTests
|
|||||||
var got = await svc.OpenReceiptAsync(id, isFinance: true);
|
var got = await svc.OpenReceiptAsync(id, isFinance: true);
|
||||||
Assert.NotNull(got);
|
Assert.NotNull(got);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reject_WritesAuditEntry_WithReason()
|
||||||
|
{
|
||||||
|
var (svc, db, fs) = Build("alice");
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.SubmitAsync(id);
|
||||||
|
|
||||||
|
var audit = new CapturingAuditLogger();
|
||||||
|
await SvcAs(db, fs, "finance", audit).RejectAsync(id, "Receipt unclear, please retake");
|
||||||
|
|
||||||
|
var entry = Assert.Single(audit.Entries);
|
||||||
|
Assert.Equal(AuditActions.ExpenseRejected, entry.Action);
|
||||||
|
Assert.Equal(AuditCategories.Business, entry.Category);
|
||||||
|
Assert.Equal(nameof(ROLAC.API.Entities.Expense), entry.EntityName);
|
||||||
|
Assert.Equal(id.ToString(), entry.EntityId);
|
||||||
|
Assert.Contains("Receipt unclear", entry.Summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resubmit_FromRejected_ReturnsToPending_AndClearsReview()
|
||||||
|
{
|
||||||
|
var (svc, db, fs) = Build("alice");
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.SubmitAsync(id);
|
||||||
|
await SvcAs(db, fs, "finance").RejectAsync(id, "Receipt missing");
|
||||||
|
|
||||||
|
// Owner fixes the issue and re-submits.
|
||||||
|
await svc.SubmitAsync(id);
|
||||||
|
|
||||||
|
var e = await db.Expenses.FindAsync(id);
|
||||||
|
Assert.Equal("PendingApproval", e!.Status);
|
||||||
|
Assert.Null(e.ReviewedBy);
|
||||||
|
Assert.Null(e.ReviewedAt);
|
||||||
|
Assert.Null(e.ReviewNotes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_OwnRejected_AsNonFinance_Succeeds()
|
||||||
|
{
|
||||||
|
// A rejected reimbursement can be corrected by its owner before re-submitting.
|
||||||
|
var (svc, db, fs) = Build("alice");
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.SubmitAsync(id);
|
||||||
|
await SvcAs(db, fs, "finance").RejectAsync(id, "Amount does not match receipt");
|
||||||
|
|
||||||
|
var edit = CloneToUpdate(Reimb());
|
||||||
|
edit.Lines[0].Amount = 77.77m;
|
||||||
|
await svc.UpdateAsync(id, edit, isFinance: false);
|
||||||
|
|
||||||
|
var e = await db.Expenses.FindAsync(id);
|
||||||
|
Assert.Equal(77.77m, e!.Amount);
|
||||||
|
Assert.Equal("Rejected", e.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveReceipt_OwnRejected_AsNonFinance_Succeeds()
|
||||||
|
{
|
||||||
|
var (svc, db, fs) = Build("alice");
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.SubmitAsync(id);
|
||||||
|
await SvcAs(db, fs, "finance").RejectAsync(id, "Receipt unclear, please retake");
|
||||||
|
|
||||||
|
using var input = new MemoryStream(Encoding.UTF8.GetBytes("img"));
|
||||||
|
await svc.SaveReceiptAsync(id, input, "retake.jpg", isFinance: false);
|
||||||
|
Assert.NotNull(await svc.OpenReceiptAsync(id, isFinance: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetById_ResolvesReviewerName_MemberFullName_EmailFallback()
|
||||||
|
{
|
||||||
|
var (svc, db, fs) = Build("alice");
|
||||||
|
// Reviewer linked to a member → shows the member's full name.
|
||||||
|
db.Members.Add(new Member { Id = 5, FirstName_en = "Sam", LastName_en = "Approver" });
|
||||||
|
db.Users.Add(new AppUser { Id = "reviewer-with-member", MemberId = 5 });
|
||||||
|
// Reviewer with no member → falls back to email.
|
||||||
|
db.Users.Add(new AppUser { Id = "reviewer-no-member", Email = "nomember@church.org" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var withMember = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.SubmitAsync(withMember);
|
||||||
|
await SvcAs(db, fs, "reviewer-with-member").ApproveAsync(withMember);
|
||||||
|
Assert.Equal("Sam Approver", (await svc.GetByIdAsync(withMember))!.ReviewedByName);
|
||||||
|
|
||||||
|
var noMember = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.SubmitAsync(noMember);
|
||||||
|
await SvcAs(db, fs, "reviewer-no-member").RejectAsync(noMember, "Duplicate submission");
|
||||||
|
Assert.Equal("nomember@church.org", (await svc.GetByIdAsync(noMember))!.ReviewedByName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
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.Expense;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class ExpenseSnapshotServiceTests
|
||||||
|
{
|
||||||
|
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 CurrentUserAccessor(mock.Object))).Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (ExpenseSnapshotService svc, AppDbContext db) Build(string userId = "u1")
|
||||||
|
{
|
||||||
|
var db = BuildDb(userId);
|
||||||
|
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Worship", Name_zh = "敬拜" });
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Facilities" });
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Rent" });
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
|
||||||
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
|
||||||
|
var http = new Mock<IHttpContextAccessor>();
|
||||||
|
http.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
|
return (new ExpenseSnapshotService(db, http.Object), db);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CreateExpenseSnapshotRequest Rent() => new()
|
||||||
|
{
|
||||||
|
Name = "Monthly Rent", MinistryId = 1, Description = "Office rent", VendorName = "Landlord X",
|
||||||
|
CheckNumber = "1001",
|
||||||
|
Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 1200m } },
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_PersistsHeaderAndLines_StampsCreator()
|
||||||
|
{
|
||||||
|
var (svc, db) = Build("creator-1");
|
||||||
|
var id = await svc.CreateAsync(Rent());
|
||||||
|
|
||||||
|
var saved = await db.ExpenseSnapshots.FindAsync(id);
|
||||||
|
Assert.Equal("Monthly Rent", saved!.Name);
|
||||||
|
Assert.Equal("creator-1", saved.CreatedBy);
|
||||||
|
Assert.Equal(1, await db.ExpenseSnapshotLines.CountAsync(l => l.SnapshotId == id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WithNoLines_Throws()
|
||||||
|
{
|
||||||
|
var (svc, _) = Build();
|
||||||
|
var r = Rent(); r.Lines.Clear();
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.CreateAsync(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WithInvalidFunctionalClass_Throws()
|
||||||
|
{
|
||||||
|
var (svc, _) = Build();
|
||||||
|
var r = Rent();
|
||||||
|
r.Lines[0].FunctionalClass = "NotAClass";
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.CreateAsync(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetById_ReturnsLines_TotalsAndCreatorName()
|
||||||
|
{
|
||||||
|
var (svc, db) = Build("creator-1");
|
||||||
|
db.Members.Add(new Member { Id = 5, FirstName_en = "Joy", LastName_en = "Wong" });
|
||||||
|
db.Users.Add(new AppUser { Id = "creator-1", MemberId = 5 });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var id = await svc.CreateAsync(Rent());
|
||||||
|
var dto = await svc.GetByIdAsync(id);
|
||||||
|
|
||||||
|
Assert.NotNull(dto);
|
||||||
|
Assert.Equal(1200m, dto!.TotalAmount);
|
||||||
|
Assert.Equal(1, dto.LineCount);
|
||||||
|
Assert.Equal("Rent", dto.Lines.Single().SubCategoryName);
|
||||||
|
Assert.Equal("Joy Wong", dto.CreatedByName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAll_ReturnsNewestFirst()
|
||||||
|
{
|
||||||
|
var (svc, _) = Build();
|
||||||
|
var first = await svc.CreateAsync(Rent());
|
||||||
|
var second = await svc.CreateAsync(Rent());
|
||||||
|
|
||||||
|
var all = await svc.GetAllAsync();
|
||||||
|
|
||||||
|
Assert.Equal(2, all.Count);
|
||||||
|
Assert.Equal(second, all[0].Id);
|
||||||
|
Assert.Equal(first, all[1].Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_RenamesAndReplacesLines()
|
||||||
|
{
|
||||||
|
var (svc, db) = Build();
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Utilities" });
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Internet" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var id = await svc.CreateAsync(Rent());
|
||||||
|
await svc.UpdateAsync(id, new UpdateExpenseSnapshotRequest
|
||||||
|
{
|
||||||
|
Name = "Monthly Internet", MinistryId = 1, Description = "ISP",
|
||||||
|
Lines = { new ExpenseLineInput { CategoryGroupId = 2, SubCategoryId = 2, Amount = 80m } },
|
||||||
|
});
|
||||||
|
|
||||||
|
var dto = await svc.GetByIdAsync(id);
|
||||||
|
Assert.Equal("Monthly Internet", dto!.Name);
|
||||||
|
Assert.Equal(80m, dto.TotalAmount);
|
||||||
|
Assert.Equal("Internet", dto.Lines.Single().SubCategoryName);
|
||||||
|
Assert.Equal(1, await db.ExpenseSnapshotLines.CountAsync(l => l.SnapshotId == id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_MissingId_Throws()
|
||||||
|
{
|
||||||
|
var (svc, _) = Build();
|
||||||
|
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.UpdateAsync(999, new UpdateExpenseSnapshotRequest
|
||||||
|
{
|
||||||
|
Name = "x", MinistryId = 1, Description = "x",
|
||||||
|
Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 1m } },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Delete_SoftDeletes_HidesFromQueries()
|
||||||
|
{
|
||||||
|
var (svc, db) = Build();
|
||||||
|
var id = await svc.CreateAsync(Rent());
|
||||||
|
|
||||||
|
await svc.DeleteAsync(id);
|
||||||
|
|
||||||
|
Assert.Empty(await svc.GetAllAsync());
|
||||||
|
Assert.Null(await db.ExpenseSnapshots.FirstOrDefaultAsync(s => s.Id == id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Delete_StampsDeletedBy()
|
||||||
|
{
|
||||||
|
var (svc, db) = Build("deleter-1");
|
||||||
|
var id = await svc.CreateAsync(Rent());
|
||||||
|
await svc.DeleteAsync(id);
|
||||||
|
var row = await db.ExpenseSnapshots.IgnoreQueryFilters().FirstAsync(s => s.Id == id);
|
||||||
|
Assert.Equal("deleter-1", row.DeletedBy);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using ROLAC.API.DTOs.Finance;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class Form1099FormServiceTests
|
||||||
|
{
|
||||||
|
/// <summary>Stub report service: only GetAnnualSummaryAsync is exercised by the CSV export.</summary>
|
||||||
|
private sealed class StubReportService : IForm1099ReportService
|
||||||
|
{
|
||||||
|
private readonly Form1099SummaryDto _summary;
|
||||||
|
public StubReportService(Form1099SummaryDto summary) => _summary = summary;
|
||||||
|
|
||||||
|
public Task<Form1099SummaryDto> GetAnnualSummaryAsync(int taxYear) => Task.FromResult(_summary);
|
||||||
|
public Task<List<Form1099BoxDto>> GetBoxesAsync() => throw new NotImplementedException();
|
||||||
|
public Task<Form1099RecipientDetailDto?> GetRecipientDetailAsync(int payeeId, int taxYear)
|
||||||
|
=> throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Form1099FormService BuildService(Form1099SummaryDto summary) =>
|
||||||
|
// IPayee1099Service and AppDbContext are only used by RenderCopyBAsync, not by the CSV path.
|
||||||
|
new Form1099FormService(new StubReportService(summary), payees: null!, db: null!);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExportFilingCsvAsync_WritesHeaderRowPerRecipientAndInvariantNumbers()
|
||||||
|
{
|
||||||
|
var summary = new Form1099SummaryDto
|
||||||
|
{
|
||||||
|
TaxYear = 2026,
|
||||||
|
Rows =
|
||||||
|
{
|
||||||
|
new Form1099RecipientRowDto
|
||||||
|
{
|
||||||
|
PayeeId = 1, LegalName = "Acme, LLC", TinLast4 = "1234", W9Status = "OnFile",
|
||||||
|
NecTotal = 1234.50m, RentsTotal = 0m, GrandTotal = 1234.50m, MeetsThreshold = true
|
||||||
|
},
|
||||||
|
new Form1099RecipientRowDto
|
||||||
|
{
|
||||||
|
PayeeId = 2, LegalName = "Bob Smith", TinLast4 = "9876", W9Status = "Missing",
|
||||||
|
NecTotal = 100m, RentsTotal = 50m, GrandTotal = 150m, MeetsThreshold = false
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var service = BuildService(summary);
|
||||||
|
var (stream, contentType, fileName) = await service.ExportFilingCsvAsync(2026);
|
||||||
|
|
||||||
|
Assert.Equal("text/csv", contentType);
|
||||||
|
Assert.Equal("1099-filing-2026.csv", fileName);
|
||||||
|
|
||||||
|
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||||
|
var text = await reader.ReadToEndAsync();
|
||||||
|
var lines = text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
// Header + one data line per row.
|
||||||
|
Assert.Equal(3, lines.Length);
|
||||||
|
Assert.Equal("LegalName,TinLast4,W9Status,Box1_NEC,Box1_Rents,Total,MeetsThreshold", lines[0]);
|
||||||
|
|
||||||
|
// A value containing a comma is quoted.
|
||||||
|
Assert.StartsWith("\"Acme, LLC\",1234,OnFile,", lines[1]);
|
||||||
|
|
||||||
|
// Invariant numeric formatting (period decimal separator) and Y/N threshold flag.
|
||||||
|
Assert.Contains("1234.50", lines[1]);
|
||||||
|
Assert.EndsWith(",Y", lines[1]);
|
||||||
|
Assert.EndsWith(",N", lines[2]);
|
||||||
|
|
||||||
|
// Sanity: the period really is the invariant separator regardless of current culture.
|
||||||
|
Assert.Equal("1234.50", 1234.50m.ToString(CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Moq;
|
||||||
|
using System.Security.Claims;
|
||||||
|
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 Form1099ReportServiceTests
|
||||||
|
{
|
||||||
|
private static AppDbContext NewDb()
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "t") })) };
|
||||||
|
var accessorMock = new Mock<IHttpContextAccessor>();
|
||||||
|
accessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||||
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(accessorMock.Object))).Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AppDbContext Seeded(out int necSubId, out int rentSubId, out int salarySubId)
|
||||||
|
{
|
||||||
|
var db = NewDb();
|
||||||
|
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Admin", DefaultFunctionalClass = "Program" });
|
||||||
|
var nec = new Form1099Box { Id = 1, BoxCode = Form1099.BoxNec1, Name_en = "NEC", FormType = "1099-NEC", SortOrder = 1 };
|
||||||
|
var rent = new Form1099Box { Id = 2, BoxCode = Form1099.BoxMisc1, Name_en = "Rent", FormType = "1099-MISC", SortOrder = 2 };
|
||||||
|
db.Form1099Boxes.AddRange(nec, rent);
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Personnel" });
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Facility" });
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Contract Labor", Form1099BoxId = 1 });
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Rent", Form1099BoxId = 2 });
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 3, GroupId = 1, Name_en = "Salary & Wages", Form1099BoxId = null });
|
||||||
|
db.SaveChanges();
|
||||||
|
necSubId = 1; rentSubId = 2; salarySubId = 3;
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddPaidExpense(AppDbContext db, int payeeId, int subId, int groupId, decimal amount, DateOnly paidOn)
|
||||||
|
{
|
||||||
|
var e = new Expense
|
||||||
|
{
|
||||||
|
MinistryId = 1, Type = "VendorPayment", Status = "Paid", PayeeId = payeeId,
|
||||||
|
Amount = amount, Description = "x", ExpenseDate = paidOn,
|
||||||
|
PaidAt = new DateTimeOffset(paidOn.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero),
|
||||||
|
Lines = [ new ExpenseLine { CategoryGroupId = groupId, SubCategoryId = subId, Amount = amount } ],
|
||||||
|
};
|
||||||
|
db.Expenses.Add(e);
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Sums_tracked_recipient_by_box_and_flags_threshold_and_w9()
|
||||||
|
{
|
||||||
|
var db = Seeded(out var necSub, out var rentSub, out _);
|
||||||
|
db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Pat Player", Is1099Tracked = true, W9Status = "Missing" });
|
||||||
|
db.SaveChanges();
|
||||||
|
AddPaidExpense(db, 10, necSub, 1, 700m, new DateOnly(2026, 3, 1));
|
||||||
|
AddPaidExpense(db, 10, rentSub, 2, 500m, new DateOnly(2026, 4, 1));
|
||||||
|
|
||||||
|
var svc = new Form1099ReportService(db);
|
||||||
|
var sum = await svc.GetAnnualSummaryAsync(2026);
|
||||||
|
|
||||||
|
var row = Assert.Single(sum.Rows);
|
||||||
|
Assert.Equal(700m, row.NecTotal);
|
||||||
|
Assert.Equal(500m, row.RentsTotal);
|
||||||
|
Assert.Equal(1200m, row.GrandTotal);
|
||||||
|
Assert.True(row.MeetsThreshold);
|
||||||
|
Assert.True(row.W9Missing);
|
||||||
|
Assert.Equal(1, sum.RecipientsAtThreshold);
|
||||||
|
Assert.Equal(1, sum.RecipientsMissingW9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Excludes_untracked_recipients_and_unmapped_and_wrong_year()
|
||||||
|
{
|
||||||
|
var db = Seeded(out var necSub, out _, out var salarySub);
|
||||||
|
db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Tracked Tim", Is1099Tracked = true, W9Status = "OnFile" });
|
||||||
|
db.Payee1099s.Add(new Payee1099 { Id = 11, LegalName = "Corp Inc", Is1099Tracked = false, W9Status = "OnFile" });
|
||||||
|
db.SaveChanges();
|
||||||
|
AddPaidExpense(db, 11, necSub, 1, 5000m, new DateOnly(2026, 5, 1)); // untracked
|
||||||
|
AddPaidExpense(db, 10, salarySub, 1, 5000m, new DateOnly(2026, 6, 1)); // unmapped box
|
||||||
|
AddPaidExpense(db, 10, necSub, 1, 5000m, new DateOnly(2025, 6, 1)); // wrong year
|
||||||
|
|
||||||
|
var sum = await new Form1099ReportService(db).GetAnnualSummaryAsync(2026);
|
||||||
|
Assert.Empty(sum.Rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Threshold_flag_is_false_below_600()
|
||||||
|
{
|
||||||
|
var db = Seeded(out var necSub, out _, out _);
|
||||||
|
db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Small Sam", Is1099Tracked = true, W9Status = "OnFile" });
|
||||||
|
db.SaveChanges();
|
||||||
|
AddPaidExpense(db, 10, necSub, 1, 599.99m, new DateOnly(2026, 7, 1));
|
||||||
|
|
||||||
|
var sum = await new Form1099ReportService(db).GetAnnualSummaryAsync(2026);
|
||||||
|
var row = Assert.Single(sum.Rows);
|
||||||
|
Assert.False(row.MeetsThreshold);
|
||||||
|
Assert.False(row.W9Missing);
|
||||||
|
Assert.Equal(0, sum.RecipientsAtThreshold);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
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 Form990ReportServiceTests
|
||||||
|
{
|
||||||
|
private static AppDbContext BuildDb()
|
||||||
|
{
|
||||||
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "t") })) };
|
||||||
|
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 async Task SeedAsync(AppDbContext db)
|
||||||
|
{
|
||||||
|
db.Form990ExpenseLines.Add(new Form990ExpenseLine { Id = 7, LineCode = "7", Name_en = "Salaries", SortOrder = 5 });
|
||||||
|
db.Form990ExpenseLines.Add(new Form990ExpenseLine { Id = 24, LineCode = "24", Name_en = "Other", SortOrder = 21 });
|
||||||
|
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Admin", DefaultFunctionalClass = "ManagementGeneral" });
|
||||||
|
db.Ministries.Add(new Ministry { Id = 2, Name_en = "Worship", DefaultFunctionalClass = "Program" });
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Personnel", Form990LineId = 24 });
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Salary", Form990LineId = 7 });
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 1, Name_en = "Misc", Form990LineId = null });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Expense Exp(int min, int sub, decimal amt, string status, string? fc = null) => new()
|
||||||
|
{
|
||||||
|
MinistryId = min, Type = "VendorPayment",
|
||||||
|
Status = status, Amount = amt, Description = "x", ExpenseDate = new DateOnly(2026, 5, 10),
|
||||||
|
Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = sub, Amount = amt, FunctionalClass = fc } },
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Statement_AggregatesByLineAndFunction_WithFallbackAndUnmappedCount()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
await SeedAsync(db);
|
||||||
|
db.Expenses.Add(Exp(2, 1, 100m, "Paid"));
|
||||||
|
db.Expenses.Add(Exp(1, 1, 40m, "Approved"));
|
||||||
|
db.Expenses.Add(Exp(2, 2, 25m, "Paid"));
|
||||||
|
db.Expenses.Add(Exp(2, 1, 999m, "Draft"));
|
||||||
|
db.Expenses.Add(Exp(1, 1, 10m, "Paid", fc: "Program"));
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var svc = new Form990ReportService(db);
|
||||||
|
|
||||||
|
var stmt = await svc.GetFunctionalExpenseStatementAsync(null, null);
|
||||||
|
|
||||||
|
var line7 = stmt.Rows.Single(r => r.LineCode == "7");
|
||||||
|
Assert.Equal(110m, line7.Program);
|
||||||
|
Assert.Equal(40m, line7.ManagementGeneral);
|
||||||
|
Assert.Equal(150m, line7.Total);
|
||||||
|
var line24 = stmt.Rows.Single(r => r.LineCode == "24");
|
||||||
|
Assert.Equal(25m, line24.Program);
|
||||||
|
Assert.Equal(1, stmt.UnmappedExpenseCount);
|
||||||
|
Assert.Equal(175m, stmt.GrandTotal);
|
||||||
|
Assert.Equal(135m, stmt.ProgramTotal);
|
||||||
|
Assert.Equal(40m, stmt.ManagementGeneralTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Statement_RespectsDateRange()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
await SeedAsync(db);
|
||||||
|
db.Expenses.Add(Exp(2, 1, 100m, "Paid"));
|
||||||
|
var older = Exp(2, 1, 500m, "Paid"); older.ExpenseDate = new DateOnly(2026, 1, 1);
|
||||||
|
db.Expenses.Add(older);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var svc = new Form990ReportService(db);
|
||||||
|
|
||||||
|
var stmt = await svc.GetFunctionalExpenseStatementAsync(new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 31));
|
||||||
|
Assert.Equal(100m, stmt.GrandTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Statement_SplitsOneExpenseAcrossLines()
|
||||||
|
{
|
||||||
|
// One invoice with two lines of different categories must land on two different 990 lines.
|
||||||
|
using var db = BuildDb();
|
||||||
|
await SeedAsync(db);
|
||||||
|
db.Expenses.Add(new Expense
|
||||||
|
{
|
||||||
|
MinistryId = 2, Type = "VendorPayment", Status = "Paid", Amount = 70m,
|
||||||
|
Description = "mixed", ExpenseDate = new DateOnly(2026, 5, 10),
|
||||||
|
Lines =
|
||||||
|
{
|
||||||
|
new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 50m }, // sub→line 7
|
||||||
|
new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 2, Amount = 20m }, // sub unmapped→group fallback line 24
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var svc = new Form990ReportService(db);
|
||||||
|
|
||||||
|
var stmt = await svc.GetFunctionalExpenseStatementAsync(null, null);
|
||||||
|
|
||||||
|
Assert.Equal(50m, stmt.Rows.Single(r => r.LineCode == "7").Program); // ministry 2 default = Program
|
||||||
|
Assert.Equal(20m, stmt.Rows.Single(r => r.LineCode == "24").Program);
|
||||||
|
Assert.Equal(70m, stmt.GrandTotal);
|
||||||
|
Assert.Equal(1, stmt.UnmappedExpenseCount); // one unmapped line
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ public class GivingCategoryServiceTests
|
|||||||
|
|
||||||
private static AppDbContext BuildDb(string userId = "test-user")
|
private static AppDbContext BuildDb(string userId = "test-user")
|
||||||
{
|
{
|
||||||
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId));
|
var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId)));
|
||||||
return new AppDbContext(
|
return new AppDbContext(
|
||||||
new DbContextOptionsBuilder<AppDbContext>()
|
new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public class GivingServiceTests
|
|||||||
|
|
||||||
private static AppDbContext BuildDb(string userId = "test-user")
|
private static AppDbContext BuildDb(string userId = "test-user")
|
||||||
{
|
{
|
||||||
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId));
|
var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId)));
|
||||||
return new AppDbContext(
|
return new AppDbContext(
|
||||||
new DbContextOptionsBuilder<AppDbContext>()
|
new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class MealAttendanceServiceTests
|
||||||
|
{
|
||||||
|
// MealAttendance is auditable, so the InMemory provider requires CreatedBy/UpdatedBy
|
||||||
|
// to be set before insert. Wire in the AuditSaveChangesInterceptor (as the other
|
||||||
|
// service tests do) so those columns are stamped automatically on SaveChanges.
|
||||||
|
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 SetCountsAsync_CreatesRowWhenMissing_AndReturnsTotals()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new MealAttendanceService(db);
|
||||||
|
var date = new DateOnly(2026, 5, 31);
|
||||||
|
|
||||||
|
var result = await svc.SetCountsAsync(date, adult: 40, youth: 12, kid: 8);
|
||||||
|
|
||||||
|
Assert.Equal("2026-05-31", result.Date);
|
||||||
|
Assert.Equal(40, result.Adult);
|
||||||
|
Assert.Equal(12, result.Youth);
|
||||||
|
Assert.Equal(8, result.Kid);
|
||||||
|
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetCountsAsync_OverwritesExistingRow_AndClampsNegativesToZero()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new MealAttendanceService(db);
|
||||||
|
var date = new DateOnly(2026, 5, 31);
|
||||||
|
await svc.SetCountsAsync(date, 40, 12, 8);
|
||||||
|
|
||||||
|
var result = await svc.SetCountsAsync(date, adult: 50, youth: -3, kid: 0);
|
||||||
|
|
||||||
|
Assert.Equal(50, result.Adult);
|
||||||
|
Assert.Equal(0, result.Youth); // negative clamped to zero
|
||||||
|
Assert.Equal(0, result.Kid);
|
||||||
|
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date)); // still one row
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ public class MemberServiceTests
|
|||||||
private static AppDbContext BuildDb(string userId = "test-user")
|
private static AppDbContext BuildDb(string userId = "test-user")
|
||||||
{
|
{
|
||||||
var accessor = BuildAccessor(userId);
|
var accessor = BuildAccessor(userId);
|
||||||
var interceptor = new AuditSaveChangesInterceptor(accessor);
|
var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(accessor));
|
||||||
return new AppDbContext(
|
return new AppDbContext(
|
||||||
new DbContextOptionsBuilder<AppDbContext>()
|
new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using Moq;
|
using Moq;
|
||||||
using ROLAC.API.Data;
|
using ROLAC.API.Data;
|
||||||
using ROLAC.API.Data.Interceptors;
|
using ROLAC.API.Data.Interceptors;
|
||||||
|
using ROLAC.API.DTOs.Ministry;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -20,7 +21,7 @@ public class MinistryServiceTests
|
|||||||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options);
|
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -41,4 +42,19 @@ public class MinistryServiceTests
|
|||||||
Assert.Equal("A", active[0].Name_en);
|
Assert.Equal("A", active[0].Name_en);
|
||||||
Assert.Equal(3, all.Count);
|
Assert.Equal(3, all.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_DefaultsFunctionalClassToProgram_AndUpdateChangesIt()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new MinistryService(db);
|
||||||
|
var id = await svc.CreateAsync(new CreateMinistryRequest { Name_en = "Worship" });
|
||||||
|
|
||||||
|
var afterCreate = (await svc.GetAllAsync(true)).Single(m => m.Id == id);
|
||||||
|
Assert.Equal("Program", afterCreate.DefaultFunctionalClass);
|
||||||
|
|
||||||
|
await svc.UpdateAsync(id, new UpdateMinistryRequest { Name_en = "Worship", DefaultFunctionalClass = "ManagementGeneral" });
|
||||||
|
var afterUpdate = (await svc.GetAllAsync(true)).Single(m => m.Id == id);
|
||||||
|
Assert.Equal("ManagementGeneral", afterUpdate.DefaultFunctionalClass);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public class MonthlyStatementServiceTests
|
|||||||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options);
|
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MonthlyStatementService Build(AppDbContext db)
|
private static MonthlyStatementService Build(AppDbContext db)
|
||||||
@@ -29,7 +29,7 @@ public class MonthlyStatementServiceTests
|
|||||||
var mock = new Mock<IHttpContextAccessor>();
|
var mock = new Mock<IHttpContextAccessor>();
|
||||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) };
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) };
|
||||||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
return new MonthlyStatementService(db, mock.Object);
|
return new MonthlyStatementService(db, mock.Object, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -42,8 +42,8 @@ public class MonthlyStatementServiceTests
|
|||||||
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Misc" });
|
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 = 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.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, Type = "VendorPayment", Status = "Paid", Amount = 300m, Description = "x", ExpenseDate = new DateOnly(2026, 5, 20), Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 300m } } });
|
||||||
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) });
|
db.Expenses.Add(new Expense { MinistryId = 1, Type = "StaffReimbursement", Status = "Approved", Amount = 999m, Description = "not paid", ExpenseDate = new DateOnly(2026, 5, 21), Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 999m } } });
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
var svc = Build(db);
|
var svc = Build(db);
|
||||||
|
|
||||||
|
|||||||
@@ -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,83 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ROLAC.API.Services.Notifications;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services.Notifications;
|
||||||
|
|
||||||
|
public class LineMessageChannelTests
|
||||||
|
{
|
||||||
|
// Stub settings provider returning fixed SMTP/Line values for the channel under test.
|
||||||
|
private sealed class StubSettings : INotificationSettingsService
|
||||||
|
{
|
||||||
|
public SmtpOptions GetSmtp() => new();
|
||||||
|
public LineOptions GetLine() => new() { ChannelAccessToken = "tok", ChannelSecret = "sec" };
|
||||||
|
public void Reload() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
return new LineMessageChannel(http, new StubSettings());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PushToUserAsync_PostsTextMessage_WithBearerToken()
|
||||||
|
{
|
||||||
|
var handler = new CapturingHandler();
|
||||||
|
var channel = BuildChannel(handler);
|
||||||
|
|
||||||
|
var result = await channel.PushToUserAsync("U123", "hello");
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Equal("https://api.line.me/v2/bot/message/push", handler.LastRequest!.RequestUri!.ToString());
|
||||||
|
Assert.Equal("Bearer", handler.LastRequest.Headers.Authorization!.Scheme);
|
||||||
|
Assert.Equal("tok", handler.LastRequest.Headers.Authorization.Parameter);
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||||
|
Assert.Equal("U123", doc.RootElement.GetProperty("to").GetString());
|
||||||
|
Assert.Equal("hello", doc.RootElement.GetProperty("messages")[0].GetProperty("text").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReplyAsync_PostsToReplyEndpoint_WithReplyToken()
|
||||||
|
{
|
||||||
|
var handler = new CapturingHandler();
|
||||||
|
var channel = BuildChannel(handler);
|
||||||
|
|
||||||
|
await channel.ReplyAsync("RTOKEN", "hi back");
|
||||||
|
|
||||||
|
Assert.Equal("https://api.line.me/v2/bot/message/reply", handler.LastRequest!.RequestUri!.ToString());
|
||||||
|
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||||
|
Assert.Equal("RTOKEN", doc.RootElement.GetProperty("replyToken").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PushToUserAsync_ReturnsFailure_OnNonSuccessStatus()
|
||||||
|
{
|
||||||
|
var handler = new CapturingHandler { StatusCode = HttpStatusCode.TooManyRequests, ResponseBody = "quota" };
|
||||||
|
var channel = BuildChannel(handler);
|
||||||
|
|
||||||
|
var result = await channel.PushToUserAsync("U123", "hello");
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Contains("429", result.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Moq;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.Data.Interceptors;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Entities.Notifications;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
using ROLAC.API.Services.Notifications;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services.Notifications;
|
||||||
|
|
||||||
|
public class LineNotificationServiceTests
|
||||||
|
{
|
||||||
|
// Records pushes; can be told to fail every call.
|
||||||
|
private sealed class FakeMessageChannel : IMessageChannel
|
||||||
|
{
|
||||||
|
public List<(string Target, string Text)> UserPushes { get; } = new();
|
||||||
|
public List<(string Target, string Text)> GroupPushes { get; } = new();
|
||||||
|
public bool Fail { get; set; }
|
||||||
|
|
||||||
|
public Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
UserPushes.Add((externalId, text));
|
||||||
|
return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null));
|
||||||
|
}
|
||||||
|
public Task<MessageSendResult> PushToGroupAsync(string externalId, string text, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
GroupPushes.Add((externalId, text));
|
||||||
|
return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null));
|
||||||
|
}
|
||||||
|
public Task<MessageSendResult> ReplyAsync(string replyToken, string text, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(new MessageSendResult(true, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CurrentUserAccessor BuildAccessor(string userId = "test-user")
|
||||||
|
{
|
||||||
|
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
|
||||||
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
|
||||||
|
var mock = new Mock<IHttpContextAccessor>();
|
||||||
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
|
return new CurrentUserAccessor(mock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AppDbContext BuildDb()
|
||||||
|
{
|
||||||
|
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor());
|
||||||
|
return new AppDbContext(
|
||||||
|
new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.AddInterceptors(interceptor)
|
||||||
|
.Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> SeedMemberAsync(AppDbContext db)
|
||||||
|
{
|
||||||
|
var member = new Member { FirstName_en = "Test", LastName_en = "User" };
|
||||||
|
db.Members.Add(member);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return member.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GenerateLineBindingCodeAsync_PersistsUnconsumedCode()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var memberId = await SeedMemberAsync(db);
|
||||||
|
var service = new LineNotificationService(db, new FakeMessageChannel());
|
||||||
|
|
||||||
|
var code = await service.GenerateLineBindingCodeAsync(memberId);
|
||||||
|
|
||||||
|
var stored = await db.LineBindingCodes.SingleAsync();
|
||||||
|
Assert.Equal(code, stored.Code);
|
||||||
|
Assert.Null(stored.ConsumedAt);
|
||||||
|
Assert.True(stored.ExpiresAt > DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryBindMemberAsync_BindsMember_AndConsumesCode()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var memberId = await SeedMemberAsync(db);
|
||||||
|
var service = new LineNotificationService(db, new FakeMessageChannel());
|
||||||
|
var code = await service.GenerateLineBindingCodeAsync(memberId);
|
||||||
|
|
||||||
|
var result = await service.TryBindMemberAsync("U999", code);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Equal(memberId, result.MemberId);
|
||||||
|
var binding = await db.MemberChannelBindings.SingleAsync();
|
||||||
|
Assert.Equal("U999", binding.ExternalId);
|
||||||
|
Assert.NotNull((await db.LineBindingCodes.SingleAsync()).ConsumedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryBindMemberAsync_Fails_ForExpiredOrUsedOrUnknownCode()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var memberId = await SeedMemberAsync(db);
|
||||||
|
db.LineBindingCodes.Add(new LineBindingCode
|
||||||
|
{
|
||||||
|
Code = "EXPIRE", MemberId = memberId, ExpiresAt = DateTime.UtcNow.AddMinutes(-1),
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var service = new LineNotificationService(db, new FakeMessageChannel());
|
||||||
|
|
||||||
|
Assert.False((await service.TryBindMemberAsync("U1", "EXPIRE")).Success); // expired
|
||||||
|
Assert.False((await service.TryBindMemberAsync("U1", "NOPE")).Success); // unknown
|
||||||
|
Assert.Empty(await db.MemberChannelBindings.ToListAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryBindMemberAsync_Rebinds_UpdatesExistingBinding()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var memberId = await SeedMemberAsync(db);
|
||||||
|
var service = new LineNotificationService(db, new FakeMessageChannel());
|
||||||
|
await service.TryBindMemberAsync("U-OLD", await service.GenerateLineBindingCodeAsync(memberId));
|
||||||
|
|
||||||
|
await service.TryBindMemberAsync("U-NEW", await service.GenerateLineBindingCodeAsync(memberId));
|
||||||
|
|
||||||
|
var binding = await db.MemberChannelBindings.SingleAsync();
|
||||||
|
Assert.Equal("U-NEW", binding.ExternalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RegisterGroupAsync_IsIdempotent_AndDeactivateFlips()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var service = new LineNotificationService(db, new FakeMessageChannel());
|
||||||
|
|
||||||
|
await service.RegisterGroupAsync("G1");
|
||||||
|
await service.RegisterGroupAsync("G1"); // second call must not duplicate
|
||||||
|
Assert.Equal(1, await db.MessagingGroups.CountAsync());
|
||||||
|
Assert.True((await db.MessagingGroups.SingleAsync()).IsActive);
|
||||||
|
|
||||||
|
await service.DeactivateGroupAsync("G1");
|
||||||
|
Assert.False((await db.MessagingGroups.SingleAsync()).IsActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendLineAsync_PushesToBoundMembersAndActiveGroups_AndLogs()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var memberId = await SeedMemberAsync(db);
|
||||||
|
db.MemberChannelBindings.Add(new MemberChannelBinding
|
||||||
|
{
|
||||||
|
MemberId = memberId, Channel = "line", ExternalId = "U-MEM", BoundAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
var activeGroup = new MessagingGroup { Channel = "line", ExternalId = "G-ON", IsActive = true, RegisteredAt = DateTime.UtcNow };
|
||||||
|
var deadGroup = new MessagingGroup { Channel = "line", ExternalId = "G-OFF", IsActive = false, RegisteredAt = DateTime.UtcNow };
|
||||||
|
db.MessagingGroups.AddRange(activeGroup, deadGroup);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var channel = new FakeMessageChannel();
|
||||||
|
var service = new LineNotificationService(db, channel);
|
||||||
|
|
||||||
|
var result = await service.SendLineAsync("notice", new[] { memberId },
|
||||||
|
new[] { activeGroup.Id, deadGroup.Id }, "admin-1");
|
||||||
|
|
||||||
|
Assert.Equal(2, result.SentCount); // member + active group only
|
||||||
|
Assert.Single(channel.UserPushes);
|
||||||
|
Assert.Single(channel.GroupPushes); // inactive group skipped
|
||||||
|
Assert.Equal(2, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Sent));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendLineAsync_RecordsFailures_WhenChannelFails()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var memberId = await SeedMemberAsync(db);
|
||||||
|
db.MemberChannelBindings.Add(new MemberChannelBinding
|
||||||
|
{
|
||||||
|
MemberId = memberId, Channel = "line", ExternalId = "U-MEM", BoundAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var service = new LineNotificationService(db, new FakeMessageChannel { Fail = true });
|
||||||
|
|
||||||
|
var result = await service.SendLineAsync("notice", new[] { memberId }, Array.Empty<int>(), "admin-1");
|
||||||
|
|
||||||
|
Assert.Equal(0, result.SentCount);
|
||||||
|
Assert.Equal(1, result.FailedCount);
|
||||||
|
Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendLineAsync_SkipsSoftDeletedMembers()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var memberId = await SeedMemberAsync(db);
|
||||||
|
db.MemberChannelBindings.Add(new MemberChannelBinding
|
||||||
|
{
|
||||||
|
MemberId = memberId, Channel = "line", ExternalId = "U-DEL", BoundAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Soft-delete the member.
|
||||||
|
var member = await db.Members.FirstAsync(m => m.Id == memberId);
|
||||||
|
member.IsDeleted = true;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var channel = new FakeMessageChannel();
|
||||||
|
var service = new LineNotificationService(db, channel);
|
||||||
|
|
||||||
|
var result = await service.SendLineAsync("notice", new[] { memberId }, Array.Empty<int>(), "admin-1");
|
||||||
|
|
||||||
|
Assert.Equal(0, result.SentCount);
|
||||||
|
Assert.Empty(channel.UserPushes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using ROLAC.API.Services.Notifications;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services.Notifications;
|
||||||
|
|
||||||
|
public class LineSignatureTests
|
||||||
|
{
|
||||||
|
private const string Secret = "test-channel-secret";
|
||||||
|
|
||||||
|
private static string Sign(string body)
|
||||||
|
{
|
||||||
|
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(Secret));
|
||||||
|
return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(body)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsValid_ReturnsTrue_ForMatchingSignature()
|
||||||
|
{
|
||||||
|
var body = """{"events":[]}""";
|
||||||
|
var signature = Sign(body);
|
||||||
|
|
||||||
|
var result = LineSignature.IsValid(Secret, Encoding.UTF8.GetBytes(body), signature);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsValid_ReturnsFalse_ForTamperedBody()
|
||||||
|
{
|
||||||
|
var signature = Sign("""{"events":[]}""");
|
||||||
|
|
||||||
|
var result = LineSignature.IsValid(Secret, Encoding.UTF8.GetBytes("""{"events":[1]}"""), signature);
|
||||||
|
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsValid_ReturnsFalse_ForNullOrEmptyHeader()
|
||||||
|
{
|
||||||
|
var body = Encoding.UTF8.GetBytes("""{"events":[]}""");
|
||||||
|
|
||||||
|
Assert.False(LineSignature.IsValid(Secret, body, null));
|
||||||
|
Assert.False(LineSignature.IsValid(Secret, body, ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ public class OfferingSessionServiceTests
|
|||||||
|
|
||||||
private static AppDbContext BuildDb(string userId = "test-user")
|
private static AppDbContext BuildDb(string userId = "test-user")
|
||||||
{
|
{
|
||||||
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId));
|
var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId)));
|
||||||
return new AppDbContext(
|
return new AppDbContext(
|
||||||
new DbContextOptionsBuilder<AppDbContext>()
|
new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
@@ -164,4 +164,27 @@ public class OfferingSessionServiceTests
|
|||||||
Assert.Equal("PP-456", line.PayPalTransactionId);
|
Assert.Equal("PP-456", line.PayPalTransactionId);
|
||||||
Assert.Equal("C-789", line.CheckNumber);
|
Assert.Equal("C-789", line.CheckNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPagedAsync_IncludesSundayAttendanceTotal_WhenRowExists()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var catId = await SeedCategoryAsync(db);
|
||||||
|
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||||
|
|
||||||
|
var withDate = new DateOnly(2026, 5, 31);
|
||||||
|
var withoutDate = new DateOnly(2026, 5, 24);
|
||||||
|
await svc.CreateAsync(BuildRequest(catId, withDate));
|
||||||
|
await svc.CreateAsync(BuildRequest(catId, withoutDate));
|
||||||
|
db.MealAttendances.Add(new MealAttendance
|
||||||
|
{ AttendanceDate = withDate, AdultCount = 40, YouthCount = 12, KidCount = 8 });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var page = await svc.GetPagedAsync(1, 20, null, null);
|
||||||
|
|
||||||
|
var withItem = page.Items.Single(i => i.SessionDate == "2026-05-31");
|
||||||
|
var withoutItem = page.Items.Single(i => i.SessionDate == "2026-05-24");
|
||||||
|
Assert.Equal(60, withItem.SundayAttendanceCount); // 40 + 12 + 8
|
||||||
|
Assert.Null(withoutItem.SundayAttendanceCount); // no attendance row -> null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Moq;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.Data.Interceptors;
|
||||||
|
using ROLAC.API.DTOs.Payee;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
using ROLAC.API.Services.Security;
|
||||||
|
using ROLAC.API.Services.Storage;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class Payee1099ServiceTests
|
||||||
|
{
|
||||||
|
// Minimal in-memory IFileStorage (mirrors the ExpenseServiceTests fake).
|
||||||
|
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 (Payee1099Service svc, AppDbContext db) Build()
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) };
|
||||||
|
var accessorMock = new Mock<IHttpContextAccessor>();
|
||||||
|
accessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||||
|
var db = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(accessorMock.Object))).Options);
|
||||||
|
var tin = new TinProtector(DataProtectionProvider.Create("ROLAC.Tests"));
|
||||||
|
return (new Payee1099Service(db, tin, new FakeStorage()), db);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_encrypts_tin_and_stores_last4_only_in_clear()
|
||||||
|
{
|
||||||
|
var (svc, db) = Build();
|
||||||
|
var id = await svc.CreateAsync(new SavePayee1099Request
|
||||||
|
{ LegalName = "Pat Player", TinType = "SSN", Tin = "123-45-6789", W9Status = "OnFile" });
|
||||||
|
|
||||||
|
var saved = await db.Payee1099s.FindAsync(id);
|
||||||
|
Assert.NotNull(saved);
|
||||||
|
Assert.Equal("6789", saved!.TinLast4);
|
||||||
|
Assert.NotNull(saved.TinEncrypted);
|
||||||
|
Assert.DoesNotContain("123-45-6789", saved.TinEncrypted!);
|
||||||
|
Assert.Equal("123-45-6789", await svc.RevealTinAsync(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_with_null_tin_keeps_existing_ciphertext()
|
||||||
|
{
|
||||||
|
var (svc, db) = Build();
|
||||||
|
var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "X", Tin = "11-2223333" });
|
||||||
|
var before = (await db.Payee1099s.FindAsync(id))!.TinEncrypted;
|
||||||
|
|
||||||
|
await svc.UpdateAsync(id, new SavePayee1099Request { LegalName = "X renamed", Tin = null });
|
||||||
|
|
||||||
|
var after = await db.Payee1099s.FindAsync(id);
|
||||||
|
Assert.Equal("X renamed", after!.LegalName);
|
||||||
|
Assert.Equal(before, after.TinEncrypted);
|
||||||
|
Assert.Equal("3333", after.TinLast4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task List_dto_masks_tin_to_last4()
|
||||||
|
{
|
||||||
|
var (svc, _) = Build();
|
||||||
|
await svc.CreateAsync(new SavePayee1099Request { LegalName = "Y", Tin = "999-88-7777" });
|
||||||
|
var list = await svc.GetAllAsync(includeInactive: true);
|
||||||
|
var item = Assert.Single(list);
|
||||||
|
Assert.Equal("7777", item.TinLast4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Delete_is_soft_and_hides_from_list()
|
||||||
|
{
|
||||||
|
var (svc, _) = Build();
|
||||||
|
var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "Z" });
|
||||||
|
await svc.DeleteAsync(id);
|
||||||
|
Assert.Empty(await svc.GetAllAsync(includeInactive: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveW9_records_document_and_round_trips_bytes()
|
||||||
|
{
|
||||||
|
var (svc, _) = Build();
|
||||||
|
var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "W9 Payee" });
|
||||||
|
|
||||||
|
var bytes = new byte[] { 1, 2, 3, 4, 5 };
|
||||||
|
await svc.SaveW9Async(id, new MemoryStream(bytes), "w9.pdf");
|
||||||
|
|
||||||
|
var dto = await svc.GetByIdAsync(id);
|
||||||
|
Assert.NotNull(dto);
|
||||||
|
Assert.True(dto!.HasW9Document);
|
||||||
|
|
||||||
|
var opened = await svc.OpenW9Async(id);
|
||||||
|
Assert.NotNull(opened);
|
||||||
|
Assert.Equal("application/pdf", opened!.Value.contentType);
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await opened.Value.stream.CopyToAsync(ms);
|
||||||
|
Assert.Equal(bytes, ms.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,30 @@
|
|||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using ROLAC.API.Services.Security;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class TinProtectorTests
|
||||||
|
{
|
||||||
|
private static TinProtector Build() =>
|
||||||
|
new TinProtector(DataProtectionProvider.Create("ROLAC.Tests"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Protect_then_Unprotect_round_trips()
|
||||||
|
{
|
||||||
|
var p = Build();
|
||||||
|
var cipher = p.Protect("123-45-6789");
|
||||||
|
Assert.NotEqual("123-45-6789", cipher);
|
||||||
|
Assert.Equal("123-45-6789", p.Unprotect(cipher));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("123-45-6789", "6789")]
|
||||||
|
[InlineData("12-3456789", "6789")]
|
||||||
|
[InlineData("7", "7")]
|
||||||
|
public void Last4_keeps_only_trailing_digits(string raw, string expected)
|
||||||
|
=> Assert.Equal(expected, TinProtector.Last4(raw));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Last4_of_null_is_null() => Assert.Null(TinProtector.Last4(null));
|
||||||
|
}
|
||||||
@@ -76,7 +76,7 @@ public class UserManagementServiceTests
|
|||||||
mgr.Setup(m => m.Users)
|
mgr.Setup(m => m.Users)
|
||||||
.Returns(new List<AppUser>().AsQueryable());
|
.Returns(new List<AppUser>().AsQueryable());
|
||||||
|
|
||||||
var svc = new UserManagementService(mgr.Object, db);
|
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||||
var result = await svc.CreateAsync(new CreateUserRequest
|
var result = await svc.CreateAsync(new CreateUserRequest
|
||||||
{
|
{
|
||||||
MemberId = member.Id,
|
MemberId = member.Id,
|
||||||
@@ -97,7 +97,7 @@ public class UserManagementServiceTests
|
|||||||
var mgr = BuildUserManager();
|
var mgr = BuildUserManager();
|
||||||
mgr.Setup(m => m.Users)
|
mgr.Setup(m => m.Users)
|
||||||
.Returns(new List<AppUser>().AsQueryable());
|
.Returns(new List<AppUser>().AsQueryable());
|
||||||
var svc = new UserManagementService(mgr.Object, db);
|
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
svc.CreateAsync(new CreateUserRequest
|
svc.CreateAsync(new CreateUserRequest
|
||||||
@@ -131,7 +131,7 @@ public class UserManagementServiceTests
|
|||||||
// The service checks _userManager.Users — we need to return the existing user
|
// The service checks _userManager.Users — we need to return the existing user
|
||||||
mgr.Setup(m => m.Users)
|
mgr.Setup(m => m.Users)
|
||||||
.Returns(new List<AppUser> { existingUser }.AsQueryable());
|
.Returns(new List<AppUser> { existingUser }.AsQueryable());
|
||||||
var svc = new UserManagementService(mgr.Object, db);
|
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
svc.CreateAsync(new CreateUserRequest
|
svc.CreateAsync(new CreateUserRequest
|
||||||
@@ -147,7 +147,7 @@ public class UserManagementServiceTests
|
|||||||
var user = new AppUser
|
var user = new AppUser
|
||||||
{ Id = "u1", UserName = "a@b.com", Email = "a@b.com", IsActive = true };
|
{ Id = "u1", UserName = "a@b.com", Email = "a@b.com", IsActive = true };
|
||||||
var mgr = BuildUserManager(findResult: user);
|
var mgr = BuildUserManager(findResult: user);
|
||||||
var svc = new UserManagementService(mgr.Object, db);
|
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||||
|
|
||||||
await svc.DeactivateAsync("u1");
|
await svc.DeactivateAsync("u1");
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ public class UserManagementServiceTests
|
|||||||
{
|
{
|
||||||
using var db = BuildDb();
|
using var db = BuildDb();
|
||||||
var mgr = BuildUserManager(findResult: null);
|
var mgr = BuildUserManager(findResult: null);
|
||||||
var svc = new UserManagementService(mgr.Object, db);
|
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||||
|
|
||||||
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeactivateAsync("missing"));
|
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeactivateAsync("missing"));
|
||||||
}
|
}
|
||||||
@@ -173,7 +173,7 @@ public class UserManagementServiceTests
|
|||||||
using var db = BuildDb();
|
using var db = BuildDb();
|
||||||
var user = new AppUser { Id = "u1", UserName = "a@b.com", Email = "a@b.com" };
|
var user = new AppUser { Id = "u1", UserName = "a@b.com", Email = "a@b.com" };
|
||||||
var mgr = BuildUserManager(findResult: user);
|
var mgr = BuildUserManager(findResult: user);
|
||||||
var svc = new UserManagementService(mgr.Object, db);
|
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||||
|
|
||||||
var pwd = await svc.ResetPasswordAsync("u1");
|
var pwd = await svc.ResetPasswordAsync("u1");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using ROLAC.API.Entities.Logging;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.TestSupport;
|
||||||
|
|
||||||
|
/// <summary>Records every audit Write so tests can assert on the emitted actions/summaries.</summary>
|
||||||
|
public sealed class CapturingAuditLogger : IAuditLogger
|
||||||
|
{
|
||||||
|
public readonly record struct Entry(string Action, string Category, string? EntityName, string? EntityId, string? Summary);
|
||||||
|
|
||||||
|
public readonly List<Entry> Entries = 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)
|
||||||
|
{
|
||||||
|
Entries.Add(new Entry(action, category, entityName, entityId, summary));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,72 @@
|
|||||||
|
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 Form990Report = "Form990Report";
|
||||||
|
public const string Form1099 = "Form1099";
|
||||||
|
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";
|
||||||
|
public const string Settings = "Settings";
|
||||||
|
|
||||||
|
/// <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,
|
||||||
|
Form990Report,
|
||||||
|
Form1099,
|
||||||
|
MonthlyStatements,
|
||||||
|
ChurchProfile,
|
||||||
|
Disbursements,
|
||||||
|
MealAttendance,
|
||||||
|
Permissions,
|
||||||
|
SystemLogs,
|
||||||
|
AuditLogs,
|
||||||
|
Settings,
|
||||||
|
];
|
||||||
|
|
||||||
|
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,10 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using ROLAC.API.DTOs.Auth;
|
using ROLAC.API.DTOs.Auth;
|
||||||
|
using ROLAC.API.DTOs.Invitations;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
namespace ROLAC.API.Controllers;
|
namespace ROLAC.API.Controllers;
|
||||||
@@ -13,11 +17,17 @@ public class AuthController : ControllerBase
|
|||||||
private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
|
private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
|
||||||
|
|
||||||
private readonly IAuthService _authService;
|
private readonly IAuthService _authService;
|
||||||
|
private readonly IInvitationService _invitations;
|
||||||
|
private readonly UserManager<AppUser> _userManager;
|
||||||
private readonly IWebHostEnvironment _env;
|
private readonly IWebHostEnvironment _env;
|
||||||
|
|
||||||
public AuthController(IAuthService authService, IWebHostEnvironment env)
|
public AuthController(
|
||||||
|
IAuthService authService, IInvitationService invitations,
|
||||||
|
UserManager<AppUser> userManager, IWebHostEnvironment env)
|
||||||
{
|
{
|
||||||
_authService = authService;
|
_authService = authService;
|
||||||
|
_invitations = invitations;
|
||||||
|
_userManager = userManager;
|
||||||
_env = env;
|
_env = env;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,17 +89,43 @@ public class AuthController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// GET /api/auth/me (dev-only diagnostic — remove before production)
|
// GET /api/auth/me
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the claims ASP.NET Core parsed from the Bearer token.
|
/// Returns the current user's identity, roles, and effective permissions.
|
||||||
/// Use this to debug 401 vs 403: if you get 200 here, the JWT validates
|
/// The SPA calls this on startup and after an admin edits the permission matrix
|
||||||
/// fine; if you then get 403 on /api/users the role claim isn't matching.
|
/// to refresh what the UI shows — without forcing a re-login.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("me")]
|
[HttpGet("me")]
|
||||||
[Authorize] // no role restriction — just needs a valid JWT
|
[Authorize]
|
||||||
public IActionResult GetMe()
|
[ProducesResponseType(typeof(UserInfo), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> GetMe()
|
||||||
|
{
|
||||||
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||||
|
if (string.IsNullOrEmpty(userId))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var user = await _userManager.FindByIdAsync(userId);
|
||||||
|
if (user is null || !user.IsActive)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var roles = await _userManager.GetRolesAsync(user);
|
||||||
|
return Ok(await _authService.BuildUserInfoAsync(user, roles));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/auth/claims (dev-only diagnostic)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the raw claims ASP.NET Core parsed from the Bearer token.
|
||||||
|
/// Use this to debug 401 vs 403: if you get 200 here, the JWT validates fine;
|
||||||
|
/// if you then get 403 on a protected endpoint the role/permission isn't matching.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("claims")]
|
||||||
|
[Authorize]
|
||||||
|
public IActionResult GetClaims()
|
||||||
{
|
{
|
||||||
var claims = User.Claims
|
var claims = User.Claims
|
||||||
.Select(c => new { c.Type, c.Value })
|
.Select(c => new { c.Type, c.Value })
|
||||||
@@ -122,6 +158,77 @@ public class AuthController : ControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /api/auth/change-password
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Changes the current user's password. Requires the correct current password and a
|
||||||
|
/// new password meeting the configured policy. On success the user's *other* sessions
|
||||||
|
/// are revoked while the current session stays active.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("change-password")]
|
||||||
|
[Authorize]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||||
|
{
|
||||||
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||||
|
if (string.IsNullOrEmpty(userId))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var currentRefresh = Request.Cookies[CookieName];
|
||||||
|
var result = await _authService.ChangePasswordAsync(
|
||||||
|
userId, request.CurrentPassword, request.NewPassword, currentRefresh);
|
||||||
|
|
||||||
|
if (!result.Succeeded)
|
||||||
|
return BadRequest(new
|
||||||
|
{
|
||||||
|
message = string.Join(" ", result.Errors.Select(error => error.Description)),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/auth/invitation/validate?token=...
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether an invitation token can still be used. Anonymous so the public
|
||||||
|
/// "set your password" page can decide what to show before the member types anything.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("invitation/validate")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(typeof(ValidateInvitationResult), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> ValidateInvitation([FromQuery] string token)
|
||||||
|
=> Ok(await _invitations.ValidateAsync(token));
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /api/auth/accept-invitation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Consumes an invitation: sets the account password and, on success, logs the member in
|
||||||
|
/// (issues the access token + refresh cookie) so first login lands straight on the portal.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("accept-invitation")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> AcceptInvitation([FromBody] AcceptInvitationRequest request)
|
||||||
|
{
|
||||||
|
var (user, error) = await _invitations.AcceptAsync(request.Token, request.NewPassword);
|
||||||
|
if (user is null)
|
||||||
|
return BadRequest(new { message = error });
|
||||||
|
|
||||||
|
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
var device = Request.Headers.UserAgent.FirstOrDefault();
|
||||||
|
var (response, raw) = await _authService.IssueSessionAsync(user, ip, device);
|
||||||
|
SetRefreshCookie(raw);
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Private helpers
|
// Private helpers
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Disbursement;
|
using ROLAC.API.DTOs.Disbursement;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -7,16 +8,18 @@ namespace ROLAC.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/church-profile")]
|
[Route("api/church-profile")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[Authorize]
|
||||||
public class ChurchProfileController : ControllerBase
|
public class ChurchProfileController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IChurchProfileService _svc;
|
private readonly IChurchProfileService _svc;
|
||||||
public ChurchProfileController(IChurchProfileService svc) => _svc = svc;
|
public ChurchProfileController(IChurchProfileService svc) => _svc = svc;
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[HasPermission(Modules.ChurchProfile, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> Get() => Ok(await _svc.GetAsync());
|
public async Task<IActionResult> Get() => Ok(await _svc.GetAsync());
|
||||||
|
|
||||||
[HttpPut]
|
[HttpPut]
|
||||||
|
[HasPermission(Modules.ChurchProfile, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Update([FromBody] UpdateChurchProfileRequest r)
|
public async Task<IActionResult> Update([FromBody] UpdateChurchProfileRequest r)
|
||||||
{
|
{
|
||||||
await _svc.UpdateAsync(r);
|
await _svc.UpdateAsync(r);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Disbursement;
|
using ROLAC.API.DTOs.Disbursement;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -8,17 +9,19 @@ namespace ROLAC.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/disbursements")]
|
[Route("api/disbursements")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[Authorize]
|
||||||
public class DisbursementsController : ControllerBase
|
public class DisbursementsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IDisbursementService _svc;
|
private readonly IDisbursementService _svc;
|
||||||
public DisbursementsController(IDisbursementService svc) => _svc = svc;
|
public DisbursementsController(IDisbursementService svc) => _svc = svc;
|
||||||
|
|
||||||
[HttpGet("approved-unpaid")]
|
[HttpGet("approved-unpaid")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetApprovedUnpaid()
|
public async Task<IActionResult> GetApprovedUnpaid()
|
||||||
=> Ok(await _svc.GetApprovedUnpaidGroupedAsync());
|
=> Ok(await _svc.GetApprovedUnpaidGroupedAsync());
|
||||||
|
|
||||||
[HttpPost("issue")]
|
[HttpPost("issue")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Issue([FromBody] IssueChecksRequest r)
|
public async Task<IActionResult> Issue([FromBody] IssueChecksRequest r)
|
||||||
{
|
{
|
||||||
try { return Ok(await _svc.IssueChecksAsync(r)); }
|
try { return Ok(await _svc.IssueChecksAsync(r)); }
|
||||||
@@ -27,12 +30,14 @@ public class DisbursementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("checks")]
|
[HttpGet("checks")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetRegister(
|
public async Task<IActionResult> GetRegister(
|
||||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? status = null,
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? status = null,
|
||||||
[FromQuery] string? search = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
[FromQuery] string? search = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
||||||
=> Ok(await _svc.GetRegisterAsync(page, pageSize, status, search, from, to));
|
=> Ok(await _svc.GetRegisterAsync(page, pageSize, status, search, from, to));
|
||||||
|
|
||||||
[HttpGet("checks/{id:int}")]
|
[HttpGet("checks/{id:int}")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetById(int id)
|
public async Task<IActionResult> GetById(int id)
|
||||||
{
|
{
|
||||||
var dto = await _svc.GetByIdAsync(id);
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
@@ -40,6 +45,7 @@ public class DisbursementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("checks/{id:int}/void")]
|
[HttpPost("checks/{id:int}/void")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> Void(int id, [FromBody] VoidCheckRequest r)
|
public async Task<IActionResult> Void(int id, [FromBody] VoidCheckRequest r)
|
||||||
{
|
{
|
||||||
try { await _svc.VoidAsync(id, r.Reason); return NoContent(); }
|
try { await _svc.VoidAsync(id, r.Reason); return NoContent(); }
|
||||||
@@ -48,6 +54,7 @@ public class DisbursementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("checks/{id:int}/pdf")]
|
[HttpGet("checks/{id:int}/pdf")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetPdf(int id)
|
public async Task<IActionResult> GetPdf(int id)
|
||||||
{
|
{
|
||||||
var result = await _svc.RenderPdfAsync(id);
|
var result = await _svc.RenderPdfAsync(id);
|
||||||
@@ -56,6 +63,7 @@ public class DisbursementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("checks/{id:int}/receipt-pdf")]
|
[HttpGet("checks/{id:int}/receipt-pdf")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetReceiptPdf(int id)
|
public async Task<IActionResult> GetReceiptPdf(int id)
|
||||||
{
|
{
|
||||||
var result = await _svc.RenderReceiptPdfAsync(id);
|
var result = await _svc.RenderReceiptPdfAsync(id);
|
||||||
@@ -64,6 +72,7 @@ public class DisbursementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("checks/{id:int}/acknowledge")]
|
[HttpPost("checks/{id:int}/acknowledge")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Approve)]
|
||||||
[RequestSizeLimit(5_242_880)]
|
[RequestSizeLimit(5_242_880)]
|
||||||
public async Task<IActionResult> Acknowledge(int id, [FromForm] IFormFile signature, [FromForm] string signedName)
|
public async Task<IActionResult> Acknowledge(int id, [FromForm] IFormFile signature, [FromForm] string signedName)
|
||||||
{
|
{
|
||||||
@@ -82,6 +91,7 @@ public class DisbursementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("checks/{id:int}/signature")]
|
[HttpGet("checks/{id:int}/signature")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetSignature(int id)
|
public async Task<IActionResult> GetSignature(int id)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.DTOs.Expense;
|
||||||
|
using ROLAC.API.Services.Ai;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/expense-ai")]
|
||||||
|
[Authorize] // Open to any authenticated user — same audience as the expense-entry form, which any
|
||||||
|
// member filing a reimbursement can reach. The endpoint only reads the category catalog.
|
||||||
|
public class ExpenseAiController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IExpenseAiServiceFactory _factory;
|
||||||
|
public ExpenseAiController(IExpenseAiServiceFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[HttpPost("assist")]
|
||||||
|
public async Task<IActionResult> Assist([FromBody] ExpenseAiAssistRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Text))
|
||||||
|
return BadRequest("Text is required.");
|
||||||
|
|
||||||
|
var svc = await _factory.ResolveAsync(ct);
|
||||||
|
var suggestion = await svc.SuggestAsync(request.Text, request.Amount, ct);
|
||||||
|
return Ok(suggestion);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Expense;
|
using ROLAC.API.DTOs.Expense;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
using ROLAC.API.Services.Ai;
|
||||||
|
|
||||||
namespace ROLAC.API.Controllers;
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
@@ -12,39 +14,57 @@ namespace ROLAC.API.Controllers;
|
|||||||
public class ExpenseCategoriesController : ControllerBase
|
public class ExpenseCategoriesController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IExpenseCategoryService _svc;
|
private readonly IExpenseCategoryService _svc;
|
||||||
public ExpenseCategoriesController(IExpenseCategoryService svc) => _svc = svc;
|
private readonly IExpenseCategoryAiServiceFactory _aiFactory;
|
||||||
|
public ExpenseCategoriesController(IExpenseCategoryService svc, IExpenseCategoryAiServiceFactory aiFactory)
|
||||||
|
{
|
||||||
|
_svc = svc;
|
||||||
|
_aiFactory = aiFactory;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
||||||
=> Ok(await _svc.GetAllAsync(includeInactive));
|
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||||
|
|
||||||
|
// Suggest an English name + Form 990 line for a category being defined. Write-gated: category
|
||||||
|
// editing is finance/admin-only, unlike the member-facing expense-ai/assist endpoint.
|
||||||
|
[HttpPost("ai-suggest")]
|
||||||
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> AiSuggest([FromBody] ExpenseCategoryAiRequest r, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(r.Name_zh) && string.IsNullOrWhiteSpace(r.Name_en))
|
||||||
|
return BadRequest("A name is required.");
|
||||||
|
|
||||||
|
var svc = await _aiFactory.ResolveAsync(ct);
|
||||||
|
return Ok(await svc.SuggestAsync(r, ct));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("groups")]
|
[HttpPost("groups")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)
|
public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)
|
||||||
=> Ok(new { id = await _svc.CreateGroupAsync(r) });
|
=> Ok(new { id = await _svc.CreateGroupAsync(r) });
|
||||||
|
|
||||||
[HttpPut("groups/{id:int}")]
|
[HttpPut("groups/{id:int}")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateExpenseGroupRequest r)
|
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateExpenseGroupRequest r)
|
||||||
{ try { await _svc.UpdateGroupAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
{ try { await _svc.UpdateGroupAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||||
|
|
||||||
[HttpDelete("groups/{id:int}")]
|
[HttpDelete("groups/{id:int}")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> DeactivateGroup(int id)
|
public async Task<IActionResult> DeactivateGroup(int id)
|
||||||
{ try { await _svc.DeactivateGroupAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
{ try { await _svc.DeactivateGroupAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||||
|
|
||||||
[HttpPost("subcategories")]
|
[HttpPost("subcategories")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> CreateSub([FromBody] CreateExpenseSubCategoryRequest r)
|
public async Task<IActionResult> CreateSub([FromBody] CreateExpenseSubCategoryRequest r)
|
||||||
{ try { return Ok(new { id = await _svc.CreateSubCategoryAsync(r) }); } catch (KeyNotFoundException) { return NotFound(); } }
|
{ try { return Ok(new { id = await _svc.CreateSubCategoryAsync(r) }); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||||
|
|
||||||
[HttpPut("subcategories/{id:int}")]
|
[HttpPut("subcategories/{id:int}")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> UpdateSub(int id, [FromBody] UpdateExpenseSubCategoryRequest r)
|
public async Task<IActionResult> UpdateSub(int id, [FromBody] UpdateExpenseSubCategoryRequest r)
|
||||||
{ try { await _svc.UpdateSubCategoryAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
{ try { await _svc.UpdateSubCategoryAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||||
|
|
||||||
[HttpDelete("subcategories/{id:int}")]
|
[HttpDelete("subcategories/{id:int}")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> DeactivateSub(int id)
|
public async Task<IActionResult> DeactivateSub(int id)
|
||||||
{ try { await _svc.DeactivateSubCategoryAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
{ try { await _svc.DeactivateSubCategoryAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
// Snapshots are reusable vendor-payment templates — a finance tool. Every action requires
|
||||||
|
// Expenses:Write (super_admin bypasses), matching who can create vendor payments.
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/expense-snapshots")]
|
||||||
|
[Authorize]
|
||||||
|
public class ExpenseSnapshotsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IExpenseSnapshotService _svc;
|
||||||
|
private readonly IPermissionService _perms;
|
||||||
|
public ExpenseSnapshotsController(IExpenseSnapshotService 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);
|
||||||
|
private async Task<bool> CanManageAsync() =>
|
||||||
|
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Write);
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetAll()
|
||||||
|
{
|
||||||
|
if (!await CanManageAsync()) return Forbid();
|
||||||
|
return Ok(await _svc.GetAllAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
{
|
||||||
|
if (!await CanManageAsync()) return Forbid();
|
||||||
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateExpenseSnapshotRequest r)
|
||||||
|
{
|
||||||
|
if (!await CanManageAsync()) return Forbid();
|
||||||
|
try { return Ok(new { id = await _svc.CreateAsync(r) }); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateExpenseSnapshotRequest r)
|
||||||
|
{
|
||||||
|
if (!await CanManageAsync()) return Forbid();
|
||||||
|
try { await _svc.UpdateAsync(id, r); 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)
|
||||||
|
{
|
||||||
|
if (!await CanManageAsync()) return Forbid();
|
||||||
|
try { await _svc.DeleteAsync(id); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,38 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Expense;
|
using ROLAC.API.DTOs.Expense;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
namespace ROLAC.API.Controllers;
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
// Class is [Authorize] only — any authenticated member may submit/view their OWN
|
||||||
|
// reimbursements. Finance-level privileges (view-all, edit-any, approve) are resolved
|
||||||
|
// against the configurable permission matrix on the "Expenses" module.
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/expenses")]
|
[Route("api/expenses")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public class ExpensesController : ControllerBase
|
public class ExpensesController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IExpenseService _svc;
|
private readonly IExpenseService _svc;
|
||||||
public ExpensesController(IExpenseService svc) => _svc = svc;
|
private readonly IPermissionService _perms;
|
||||||
|
public ExpensesController(IExpenseService svc, IPermissionService perms)
|
||||||
|
{
|
||||||
|
_svc = svc;
|
||||||
|
_perms = perms;
|
||||||
|
}
|
||||||
|
|
||||||
private bool IsFinance() => User.IsInRole("finance") || User.IsInRole("super_admin");
|
private List<string> Roles() => User.FindAll("role").Select(claim => claim.Value).ToList();
|
||||||
private bool CanViewAll() => IsFinance() || User.IsInRole("pastor");
|
private bool IsSuperAdmin() => User.IsInRole(PermissionAuthorizationHandler.SuperAdminRole);
|
||||||
|
|
||||||
|
// Can manage any expense (edit/delete/upload on others' records). Maps to Expenses:Write.
|
||||||
|
private async Task<bool> CanManageAsync() =>
|
||||||
|
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Write);
|
||||||
|
|
||||||
|
// Can view all expenses (not just own). Maps to Expenses:Read (finance + pastor by default).
|
||||||
|
private async Task<bool> CanViewAllAsync() =>
|
||||||
|
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Read);
|
||||||
|
|
||||||
// User id lives in the "sub" claim (NameClaimType="sub"); NameIdentifier is absent at runtime.
|
// User id lives in the "sub" claim (NameClaimType="sub"); NameIdentifier is absent at runtime.
|
||||||
private string CurrentUserId() =>
|
private string CurrentUserId() =>
|
||||||
@@ -28,7 +45,7 @@ public class ExpensesController : ControllerBase
|
|||||||
[FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null,
|
[FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null,
|
||||||
[FromQuery] int? subCategoryId = null, [FromQuery] string? statuses = null)
|
[FromQuery] int? subCategoryId = null, [FromQuery] string? statuses = null)
|
||||||
{
|
{
|
||||||
if (!CanViewAll()) return Forbid();
|
if (!await CanViewAllAsync()) return Forbid();
|
||||||
return Ok(await _svc.GetPagedAsync(page, pageSize, search, ministryId, categoryGroupId, status, from, to, subCategoryId, statuses));
|
return Ok(await _svc.GetPagedAsync(page, pageSize, search, ministryId, categoryGroupId, status, from, to, subCategoryId, statuses));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,21 +60,21 @@ public class ExpensesController : ControllerBase
|
|||||||
{
|
{
|
||||||
var dto = await _svc.GetByIdAsync(id);
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
if (dto is null) return NotFound();
|
if (dto is null) return NotFound();
|
||||||
if (!CanViewAll() && dto.SubmittedBy != CurrentUserId()) return Forbid();
|
if (!await CanViewAllAsync() && dto.SubmittedBy != CurrentUserId()) return Forbid();
|
||||||
return Ok(dto);
|
return Ok(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateExpenseRequest r)
|
public async Task<IActionResult> Create([FromBody] CreateExpenseRequest r)
|
||||||
{
|
{
|
||||||
try { return Ok(new { id = await _svc.CreateAsync(r, IsFinance()) }); }
|
try { return Ok(new { id = await _svc.CreateAsync(r, await CanManageAsync()) }); }
|
||||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateExpenseRequest r)
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateExpenseRequest r)
|
||||||
{
|
{
|
||||||
try { await _svc.UpdateAsync(id, r, IsFinance()); return NoContent(); }
|
try { await _svc.UpdateAsync(id, r, await CanManageAsync()); return NoContent(); }
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
}
|
}
|
||||||
@@ -65,7 +82,7 @@ public class ExpensesController : ControllerBase
|
|||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
public async Task<IActionResult> Delete(int id)
|
public async Task<IActionResult> Delete(int id)
|
||||||
{
|
{
|
||||||
try { await _svc.DeleteAsync(id, IsFinance()); return NoContent(); }
|
try { await _svc.DeleteAsync(id, await CanManageAsync()); return NoContent(); }
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
}
|
}
|
||||||
@@ -79,7 +96,7 @@ public class ExpensesController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:int}/approve")]
|
[HttpPost("{id:int}/approve")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
|
||||||
public async Task<IActionResult> Approve(int id)
|
public async Task<IActionResult> Approve(int id)
|
||||||
{
|
{
|
||||||
try { await _svc.ApproveAsync(id); return NoContent(); }
|
try { await _svc.ApproveAsync(id); return NoContent(); }
|
||||||
@@ -88,7 +105,7 @@ public class ExpensesController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:int}/reject")]
|
[HttpPost("{id:int}/reject")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
|
||||||
public async Task<IActionResult> Reject(int id, [FromBody] RejectExpenseRequest r)
|
public async Task<IActionResult> Reject(int id, [FromBody] RejectExpenseRequest r)
|
||||||
{
|
{
|
||||||
try { await _svc.RejectAsync(id, r.ReviewNotes); return NoContent(); }
|
try { await _svc.RejectAsync(id, r.ReviewNotes); return NoContent(); }
|
||||||
@@ -97,7 +114,7 @@ public class ExpensesController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:int}/pay")]
|
[HttpPost("{id:int}/pay")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
|
||||||
public async Task<IActionResult> Pay(int id, [FromBody] PayExpenseRequest r)
|
public async Task<IActionResult> Pay(int id, [FromBody] PayExpenseRequest r)
|
||||||
{
|
{
|
||||||
try { await _svc.PayAsync(id, r.CheckNumber, r.PaidAt); return NoContent(); }
|
try { await _svc.PayAsync(id, r.CheckNumber, r.PaidAt); return NoContent(); }
|
||||||
@@ -115,7 +132,7 @@ public class ExpensesController : ControllerBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var stream = file.OpenReadStream();
|
await using var stream = file.OpenReadStream();
|
||||||
await _svc.SaveReceiptAsync(id, stream, file.FileName, IsFinance());
|
await _svc.SaveReceiptAsync(id, stream, file.FileName, await CanManageAsync());
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
@@ -127,7 +144,7 @@ public class ExpensesController : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await _svc.OpenReceiptAsync(id, IsFinance());
|
var result = await _svc.OpenReceiptAsync(id, await CanManageAsync());
|
||||||
if (result is null) return NotFound();
|
if (result is null) return NotFound();
|
||||||
return File(result.Value.stream, result.Value.contentType);
|
return File(result.Value.stream, result.Value.contentType);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
namespace ROLAC.API.Controllers;
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/finance-dashboard")]
|
[Route("api/finance-dashboard")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.FinanceDashboard, PermissionActions.Read)]
|
||||||
public class FinanceDashboardController : ControllerBase
|
public class FinanceDashboardController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IFinanceDashboardService _svc;
|
private readonly IFinanceDashboardService _svc;
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/form1099-report")]
|
||||||
|
[HasPermission(Modules.Form1099, PermissionActions.Read)]
|
||||||
|
public class Form1099ReportController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IForm1099ReportService _svc;
|
||||||
|
private readonly I1099FormService _form;
|
||||||
|
public Form1099ReportController(IForm1099ReportService svc, I1099FormService form)
|
||||||
|
{
|
||||||
|
_svc = svc;
|
||||||
|
_form = form;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("boxes")]
|
||||||
|
public async Task<IActionResult> Boxes() => Ok(await _svc.GetBoxesAsync());
|
||||||
|
|
||||||
|
[HttpGet("summary")]
|
||||||
|
public async Task<IActionResult> Summary([FromQuery] int taxYear)
|
||||||
|
=> Ok(await _svc.GetAnnualSummaryAsync(taxYear));
|
||||||
|
|
||||||
|
[HttpGet("recipient/{payeeId:int}")]
|
||||||
|
public async Task<IActionResult> Recipient(int payeeId, [FromQuery] int taxYear)
|
||||||
|
=> await _svc.GetRecipientDetailAsync(payeeId, taxYear) is { } d ? Ok(d) : NotFound();
|
||||||
|
|
||||||
|
[HttpGet("recipient/{payeeId:int}/copy-b")]
|
||||||
|
public async Task<IActionResult> CopyB(int payeeId, [FromQuery] int taxYear)
|
||||||
|
{
|
||||||
|
var (stream, contentType, fileName) = await _form.RenderCopyBAsync(payeeId, taxYear);
|
||||||
|
return File(stream, contentType, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("export-csv")]
|
||||||
|
public async Task<IActionResult> ExportCsv([FromQuery] int taxYear)
|
||||||
|
{
|
||||||
|
var (stream, contentType, fileName) = await _form.ExportFilingCsvAsync(taxYear);
|
||||||
|
return File(stream, contentType, fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/form990-report")]
|
||||||
|
[HasPermission(Modules.Form990Report, PermissionActions.Read)]
|
||||||
|
public class Form990ReportController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IForm990ReportService _svc;
|
||||||
|
public Form990ReportController(IForm990ReportService svc) => _svc = svc;
|
||||||
|
|
||||||
|
[HttpGet("lines")]
|
||||||
|
public async Task<IActionResult> Lines() => Ok(await _svc.GetLinesAsync());
|
||||||
|
|
||||||
|
[HttpGet("functional-expenses")]
|
||||||
|
public async Task<IActionResult> FunctionalExpenses([FromQuery] DateOnly? from, [FromQuery] DateOnly? to)
|
||||||
|
=> Ok(await _svc.GetFunctionalExpenseStatementAsync(from, to));
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Giving;
|
using ROLAC.API.DTOs.Giving;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -7,17 +8,19 @@ namespace ROLAC.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/giving-categories")]
|
[Route("api/giving-categories")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[Authorize]
|
||||||
public class GivingCategoriesController : ControllerBase
|
public class GivingCategoriesController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IGivingCategoryService _svc;
|
private readonly IGivingCategoryService _svc;
|
||||||
public GivingCategoriesController(IGivingCategoryService svc) => _svc = svc;
|
public GivingCategoriesController(IGivingCategoryService svc) => _svc = svc;
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[HasPermission(Modules.GivingCategories, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
||||||
=> Ok(await _svc.GetAllAsync(includeInactive));
|
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.GivingCategories, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateGivingCategoryRequest request)
|
public async Task<IActionResult> Create([FromBody] CreateGivingCategoryRequest request)
|
||||||
{
|
{
|
||||||
var id = await _svc.CreateAsync(request);
|
var id = await _svc.CreateAsync(request);
|
||||||
@@ -25,6 +28,7 @@ public class GivingCategoriesController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
|
[HasPermission(Modules.GivingCategories, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingCategoryRequest request)
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingCategoryRequest request)
|
||||||
{
|
{
|
||||||
try { await _svc.UpdateAsync(id, request); return NoContent(); }
|
try { await _svc.UpdateAsync(id, request); return NoContent(); }
|
||||||
@@ -32,6 +36,7 @@ public class GivingCategoriesController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
|
[HasPermission(Modules.GivingCategories, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> Deactivate(int id)
|
public async Task<IActionResult> Deactivate(int id)
|
||||||
{
|
{
|
||||||
try { await _svc.DeactivateAsync(id); return NoContent(); }
|
try { await _svc.DeactivateAsync(id); return NoContent(); }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Giving;
|
using ROLAC.API.DTOs.Giving;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -7,13 +8,14 @@ namespace ROLAC.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/givings")]
|
[Route("api/givings")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[Authorize]
|
||||||
public class GivingsController : ControllerBase
|
public class GivingsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IGivingService _svc;
|
private readonly IGivingService _svc;
|
||||||
public GivingsController(IGivingService svc) => _svc = svc;
|
public GivingsController(IGivingService svc) => _svc = svc;
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[HasPermission(Modules.Givings, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetPaged(
|
public async Task<IActionResult> GetPaged(
|
||||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
||||||
[FromQuery] string? search = null, [FromQuery] int? categoryId = null,
|
[FromQuery] string? search = null, [FromQuery] int? categoryId = null,
|
||||||
@@ -21,6 +23,7 @@ public class GivingsController : ControllerBase
|
|||||||
=> Ok(await _svc.GetPagedAsync(page, pageSize, search, categoryId, from, to));
|
=> Ok(await _svc.GetPagedAsync(page, pageSize, search, categoryId, from, to));
|
||||||
|
|
||||||
[HttpGet("{id:int}")]
|
[HttpGet("{id:int}")]
|
||||||
|
[HasPermission(Modules.Givings, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetById(int id)
|
public async Task<IActionResult> GetById(int id)
|
||||||
{
|
{
|
||||||
var dto = await _svc.GetByIdAsync(id);
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
@@ -28,6 +31,7 @@ public class GivingsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.Givings, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateGivingRequest request)
|
public async Task<IActionResult> Create([FromBody] CreateGivingRequest request)
|
||||||
{
|
{
|
||||||
var id = await _svc.CreateAsync(request);
|
var id = await _svc.CreateAsync(request);
|
||||||
@@ -35,6 +39,7 @@ public class GivingsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
|
[HasPermission(Modules.Givings, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingRequest request)
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingRequest request)
|
||||||
{
|
{
|
||||||
try { await _svc.UpdateAsync(id, request); return NoContent(); }
|
try { await _svc.UpdateAsync(id, request); return NoContent(); }
|
||||||
@@ -43,6 +48,7 @@ public class GivingsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
|
[HasPermission(Modules.Givings, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> Delete(int id)
|
public async Task<IActionResult> Delete(int id)
|
||||||
{
|
{
|
||||||
try { await _svc.DeleteAsync(id); return NoContent(); }
|
try { await _svc.DeleteAsync(id); return NoContent(); }
|
||||||
|
|||||||
@@ -0,0 +1,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,39 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
|
using ROLAC.API.DTOs.Invitations;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Admin endpoints for generating and e-mailing first-login invitation links.
|
||||||
|
/// The public consume/validate endpoints live on <see cref="AuthController"/> so they can set the
|
||||||
|
/// refresh-token cookie and stay anonymous.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/invitations")]
|
||||||
|
[Authorize]
|
||||||
|
public class InvitationsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IInvitationService _invitations;
|
||||||
|
public InvitationsController(IInvitationService invitations) => _invitations = invitations;
|
||||||
|
|
||||||
|
/// <summary>POST /api/invitations — generate a link for a member; returns { token, expiresAt }.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateInvitationRequest request)
|
||||||
|
{
|
||||||
|
try { return Ok(await _invitations.CreateAsync(request)); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POST /api/invitations/send — e-mail an already-generated link to the member.</summary>
|
||||||
|
[HttpPost("send")]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> Send([FromBody] SendInvitationRequest request)
|
||||||
|
{
|
||||||
|
try { await _invitations.SendEmailAsync(request.MemberId, request.Link); return NoContent(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
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 INotificationSettingsService _settings;
|
||||||
|
|
||||||
|
public LineWebhookController(
|
||||||
|
ILineNotificationService line, IMessageChannel channel, INotificationSettingsService settings)
|
||||||
|
{
|
||||||
|
_line = line;
|
||||||
|
_channel = channel;
|
||||||
|
_settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
[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(_settings.GetLine().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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.DTOs.MealAttendance;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
namespace ROLAC.API.Controllers;
|
namespace ROLAC.API.Controllers;
|
||||||
@@ -16,11 +17,17 @@ public class MealAttendanceController : ControllerBase
|
|||||||
[HttpGet("today")]
|
[HttpGet("today")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetToday()
|
public async Task<IActionResult> GetToday()
|
||||||
=> Ok(await _svc.GetOrCreateAsync(_svc.Today));
|
=> Ok(await _svc.GetOrCreateAsync(_svc.ServiceDay));
|
||||||
|
|
||||||
/// <summary>Daily counts within a date range, for the back-office dashboard chart.</summary>
|
/// <summary>Daily counts within a date range, for the back-office dashboard chart.</summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> GetRange([FromQuery] DateOnly from, [FromQuery] DateOnly to)
|
public async Task<IActionResult> GetRange([FromQuery] DateOnly from, [FromQuery] DateOnly to)
|
||||||
=> Ok(await _svc.GetRangeAsync(from, to));
|
=> Ok(await _svc.GetRangeAsync(from, to));
|
||||||
|
|
||||||
|
/// <summary>Overwrite a specific Sunday's counts (back-office editor). Authenticated only.</summary>
|
||||||
|
[HttpPut("{date}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> SetCounts(DateOnly date, [FromBody] SetAttendanceRequest body)
|
||||||
|
=> Ok(await _svc.SetCountsAsync(date, body.Adult, body.Youth, body.Kid));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Members;
|
using ROLAC.API.DTOs.Members;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ public class MembersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>GET /api/members?page=1&pageSize=20&search=Chen&status=Member&hasUser=false</summary>
|
/// <summary>GET /api/members?page=1&pageSize=20&search=Chen&status=Member&hasUser=false</summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Roles = "super_admin,secretary,pastor")]
|
[HasPermission(Modules.Members, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetPaged(
|
public async Task<IActionResult> GetPaged(
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
[FromQuery] int pageSize = 20,
|
[FromQuery] int pageSize = 20,
|
||||||
@@ -26,7 +27,7 @@ public class MembersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>GET /api/members/{id}</summary>
|
/// <summary>GET /api/members/{id}</summary>
|
||||||
[HttpGet("{id:int}")]
|
[HttpGet("{id:int}")]
|
||||||
[Authorize(Roles = "super_admin,secretary,pastor")]
|
[HasPermission(Modules.Members, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetById(int id)
|
public async Task<IActionResult> GetById(int id)
|
||||||
{
|
{
|
||||||
var dto = await _members.GetByIdAsync(id);
|
var dto = await _members.GetByIdAsync(id);
|
||||||
@@ -35,7 +36,7 @@ public class MembersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>POST /api/members</summary>
|
/// <summary>POST /api/members</summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Roles = "super_admin,secretary")]
|
[HasPermission(Modules.Members, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateMemberRequest request)
|
public async Task<IActionResult> Create([FromBody] CreateMemberRequest request)
|
||||||
{
|
{
|
||||||
var id = await _members.CreateAsync(request);
|
var id = await _members.CreateAsync(request);
|
||||||
@@ -44,7 +45,7 @@ public class MembersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>PUT /api/members/{id}</summary>
|
/// <summary>PUT /api/members/{id}</summary>
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
[Authorize(Roles = "super_admin,secretary")]
|
[HasPermission(Modules.Members, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateMemberRequest request)
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateMemberRequest request)
|
||||||
{
|
{
|
||||||
try { await _members.UpdateAsync(id, request); return NoContent(); }
|
try { await _members.UpdateAsync(id, request); return NoContent(); }
|
||||||
@@ -53,7 +54,7 @@ public class MembersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>DELETE /api/members/{id} — soft delete</summary>
|
/// <summary>DELETE /api/members/{id} — soft delete</summary>
|
||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
[Authorize(Roles = "super_admin,secretary")]
|
[HasPermission(Modules.Members, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> Delete(int id)
|
public async Task<IActionResult> Delete(int id)
|
||||||
{
|
{
|
||||||
try { await _members.DeleteAsync(id); return NoContent(); }
|
try { await _members.DeleteAsync(id); return NoContent(); }
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
|
using ROLAC.API.DTOs.Ministry;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
namespace ROLAC.API.Controllers;
|
namespace ROLAC.API.Controllers;
|
||||||
@@ -13,6 +15,31 @@ public class MinistriesController : ControllerBase
|
|||||||
public MinistriesController(IMinistryService svc) => _svc = svc;
|
public MinistriesController(IMinistryService svc) => _svc = svc;
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[HasPermission(Modules.Ministries, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
||||||
=> Ok(await _svc.GetAllAsync(includeInactive));
|
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.Ministries, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateMinistryRequest request)
|
||||||
|
{
|
||||||
|
var id = await _svc.CreateAsync(request);
|
||||||
|
return CreatedAtAction(nameof(GetAll), new { id }, new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
[HasPermission(Modules.Ministries, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateMinistryRequest request)
|
||||||
|
{
|
||||||
|
try { await _svc.UpdateAsync(id, request); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
[HasPermission(Modules.Ministries, PermissionActions.Delete)]
|
||||||
|
public async Task<IActionResult> Deactivate(int id)
|
||||||
|
{
|
||||||
|
try { await _svc.DeactivateAsync(id); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Expense;
|
using ROLAC.API.DTOs.Expense;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -7,17 +8,19 @@ namespace ROLAC.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/monthly-statements")]
|
[Route("api/monthly-statements")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[Authorize]
|
||||||
public class MonthlyStatementsController : ControllerBase
|
public class MonthlyStatementsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IMonthlyStatementService _svc;
|
private readonly IMonthlyStatementService _svc;
|
||||||
public MonthlyStatementsController(IMonthlyStatementService svc) => _svc = svc;
|
public MonthlyStatementsController(IMonthlyStatementService svc) => _svc = svc;
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[HasPermission(Modules.MonthlyStatements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetAll([FromQuery] int? year = null)
|
public async Task<IActionResult> GetAll([FromQuery] int? year = null)
|
||||||
=> Ok(await _svc.GetAllAsync(year));
|
=> Ok(await _svc.GetAllAsync(year));
|
||||||
|
|
||||||
[HttpGet("{id:int}")]
|
[HttpGet("{id:int}")]
|
||||||
|
[HasPermission(Modules.MonthlyStatements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetById(int id)
|
public async Task<IActionResult> GetById(int id)
|
||||||
{
|
{
|
||||||
var dto = await _svc.GetByIdAsync(id);
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
@@ -25,6 +28,7 @@ public class MonthlyStatementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.MonthlyStatements, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateMonthlyStatementRequest r)
|
public async Task<IActionResult> Create([FromBody] CreateMonthlyStatementRequest r)
|
||||||
{
|
{
|
||||||
try { return Ok(new { id = await _svc.CreateAsync(r) }); }
|
try { return Ok(new { id = await _svc.CreateAsync(r) }); }
|
||||||
@@ -32,6 +36,7 @@ public class MonthlyStatementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
|
[HasPermission(Modules.MonthlyStatements, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateMonthlyStatementRequest r)
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateMonthlyStatementRequest r)
|
||||||
{
|
{
|
||||||
try { await _svc.UpdateAsync(id, r); return NoContent(); }
|
try { await _svc.UpdateAsync(id, r); return NoContent(); }
|
||||||
@@ -40,6 +45,7 @@ public class MonthlyStatementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:int}/finalize")]
|
[HttpPost("{id:int}/finalize")]
|
||||||
|
[HasPermission(Modules.MonthlyStatements, PermissionActions.Approve)]
|
||||||
public async Task<IActionResult> Finalize(int id)
|
public async Task<IActionResult> Finalize(int id)
|
||||||
{
|
{
|
||||||
try { await _svc.FinalizeAsync(id); return NoContent(); }
|
try { await _svc.FinalizeAsync(id); return NoContent(); }
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.DTOs.Notifications;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
using ROLAC.API.Services.Notifications;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Admin endpoints for the notification module (API-only phase). Binding-code generation, group
|
||||||
|
/// management, send history, and manual send — the manual send endpoints are the only way to fire
|
||||||
|
/// a message before a UI exists; programmatic callers use the services directly.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/notifications")]
|
||||||
|
[Authorize]
|
||||||
|
public sealed class NotificationsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IEmailService _email;
|
||||||
|
private readonly ILineNotificationService _line;
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly CurrentUserAccessor _currentUser;
|
||||||
|
|
||||||
|
public NotificationsController(
|
||||||
|
IEmailService email, ILineNotificationService line,
|
||||||
|
AppDbContext db, CurrentUserAccessor currentUser)
|
||||||
|
{
|
||||||
|
_email = email;
|
||||||
|
_line = line;
|
||||||
|
_db = db;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("members/{id:int}/line-binding-code")]
|
||||||
|
public async Task<IActionResult> GenerateBindingCode(int id, CancellationToken ct)
|
||||||
|
=> Ok(new { code = await _line.GenerateLineBindingCodeAsync(id, ct) });
|
||||||
|
|
||||||
|
[HttpGet("groups")]
|
||||||
|
public async Task<IActionResult> Groups(CancellationToken ct)
|
||||||
|
=> Ok(await _db.MessagingGroups
|
||||||
|
.OrderBy(g => g.Id)
|
||||||
|
.Select(g => new { g.Id, g.Name, g.IsActive, g.RegisteredAt })
|
||||||
|
.ToListAsync(ct));
|
||||||
|
|
||||||
|
[HttpPut("groups/{id:int}")]
|
||||||
|
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateGroupRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var group = await _db.MessagingGroups.FirstOrDefaultAsync(g => g.Id == id, ct);
|
||||||
|
if (group is null) return NotFound();
|
||||||
|
|
||||||
|
group.Name = request.Name;
|
||||||
|
group.IsActive = request.IsActive;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("history")]
|
||||||
|
public async Task<IActionResult> History(
|
||||||
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var size = Math.Clamp(pageSize, 1, 200);
|
||||||
|
var skip = (Math.Max(page, 1) - 1) * size;
|
||||||
|
|
||||||
|
var query = _db.NotificationLogs.OrderByDescending(l => l.SentAt);
|
||||||
|
var total = await query.CountAsync(ct);
|
||||||
|
var items = await query
|
||||||
|
.Skip(skip).Take(size)
|
||||||
|
.Select(l => new
|
||||||
|
{
|
||||||
|
l.Id, l.Channel, l.TargetType, l.TargetExternalId, l.Subject,
|
||||||
|
l.Status, l.Error, l.SentByUserId, l.SentAt,
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return Ok(new { total, items });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("send-line")]
|
||||||
|
public async Task<IActionResult> SendLine([FromBody] SendLineRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _line.SendLineAsync(
|
||||||
|
request.Body, request.MemberIds ?? [], request.GroupIds ?? [],
|
||||||
|
_currentUser.UserIdOrSystem, ct));
|
||||||
|
|
||||||
|
[HttpPost("send-email")]
|
||||||
|
public async Task<IActionResult> SendEmail([FromBody] SendEmailRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _email.SendAsync(new EmailMessage(
|
||||||
|
MemberIds: request.MemberIds ?? [],
|
||||||
|
Addresses: request.Addresses ?? [],
|
||||||
|
Subject: request.Subject,
|
||||||
|
HtmlBody: request.HtmlBody,
|
||||||
|
Attachments: null,
|
||||||
|
SentByUserId: _currentUser.UserIdOrSystem), ct));
|
||||||
|
}
|
||||||
@@ -64,6 +64,7 @@ public class OfferingEntryController : ControllerBase
|
|||||||
NickName = request.NickName,
|
NickName = request.NickName,
|
||||||
FirstName_zh = request.FirstName_zh,
|
FirstName_zh = request.FirstName_zh,
|
||||||
LastName_zh = request.LastName_zh,
|
LastName_zh = request.LastName_zh,
|
||||||
|
Entity = request.Entity,
|
||||||
PhoneCell = request.PhoneCell,
|
PhoneCell = request.PhoneCell,
|
||||||
Status = "Visitor",
|
Status = "Visitor",
|
||||||
Country = "USA",
|
Country = "USA",
|
||||||
@@ -73,6 +74,7 @@ public class OfferingEntryController : ControllerBase
|
|||||||
{
|
{
|
||||||
Id = id, NickName = request.NickName,
|
Id = id, NickName = request.NickName,
|
||||||
FirstName_en = request.FirstName_en, LastName_en = request.LastName_en,
|
FirstName_en = request.FirstName_en, LastName_en = request.LastName_en,
|
||||||
|
Entity = request.Entity,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Giving;
|
using ROLAC.API.DTOs.Giving;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -7,23 +8,26 @@ namespace ROLAC.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/offering-sessions")]
|
[Route("api/offering-sessions")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[Authorize]
|
||||||
public class OfferingSessionsController : ControllerBase
|
public class OfferingSessionsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IOfferingSessionService _svc;
|
private readonly IOfferingSessionService _svc;
|
||||||
public OfferingSessionsController(IOfferingSessionService svc) => _svc = svc;
|
public OfferingSessionsController(IOfferingSessionService svc) => _svc = svc;
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetPaged(
|
public async Task<IActionResult> GetPaged(
|
||||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
||||||
[FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
[FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
||||||
=> Ok(await _svc.GetPagedAsync(page, pageSize, from, to));
|
=> Ok(await _svc.GetPagedAsync(page, pageSize, from, to));
|
||||||
|
|
||||||
[HttpGet("check-date")]
|
[HttpGet("check-date")]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> CheckDate([FromQuery] DateOnly date)
|
public async Task<IActionResult> CheckDate([FromQuery] DateOnly date)
|
||||||
=> Ok(new { exists = await _svc.DateExistsAsync(date) });
|
=> Ok(new { exists = await _svc.DateExistsAsync(date) });
|
||||||
|
|
||||||
[HttpGet("{id:int}")]
|
[HttpGet("{id:int}")]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetById(int id)
|
public async Task<IActionResult> GetById(int id)
|
||||||
{
|
{
|
||||||
var dto = await _svc.GetByIdAsync(id);
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
@@ -31,6 +35,7 @@ public class OfferingSessionsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateOfferingSessionRequest request)
|
public async Task<IActionResult> Create([FromBody] CreateOfferingSessionRequest request)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -42,6 +47,7 @@ public class OfferingSessionsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:int}/reopen")]
|
[HttpPost("{id:int}/reopen")]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Approve)]
|
||||||
public async Task<IActionResult> Reopen(int id)
|
public async Task<IActionResult> Reopen(int id)
|
||||||
{
|
{
|
||||||
try { await _svc.ReopenAsync(id); return NoContent(); }
|
try { await _svc.ReopenAsync(id); return NoContent(); }
|
||||||
@@ -50,6 +56,7 @@ public class OfferingSessionsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Replace(int id, [FromBody] CreateOfferingSessionRequest request)
|
public async Task<IActionResult> Replace(int id, [FromBody] CreateOfferingSessionRequest request)
|
||||||
{
|
{
|
||||||
try { await _svc.ReplaceAsync(id, request); return NoContent(); }
|
try { await _svc.ReplaceAsync(id, request); return NoContent(); }
|
||||||
@@ -60,6 +67,7 @@ public class OfferingSessionsController : ControllerBase
|
|||||||
// ── Paper-proof PDF (merged client-side, one file per session) ───────────
|
// ── Paper-proof PDF (merged client-side, one file per session) ───────────
|
||||||
|
|
||||||
[HttpPost("{id:int}/proof")]
|
[HttpPost("{id:int}/proof")]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
|
||||||
[RequestSizeLimit(52_428_800)] // 50 MB — a merged multi-image PDF is larger than one receipt
|
[RequestSizeLimit(52_428_800)] // 50 MB — a merged multi-image PDF is larger than one receipt
|
||||||
public async Task<IActionResult> UploadProof(int id, IFormFile file)
|
public async Task<IActionResult> UploadProof(int id, IFormFile file)
|
||||||
{
|
{
|
||||||
@@ -75,6 +83,7 @@ public class OfferingSessionsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:int}/proof")]
|
[HttpGet("{id:int}/proof")]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetProof(int id)
|
public async Task<IActionResult> GetProof(int id)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -87,6 +96,7 @@ public class OfferingSessionsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:int}/proof")]
|
[HttpDelete("{id:int}/proof")]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> DeleteProof(int id)
|
public async Task<IActionResult> DeleteProof(int id)
|
||||||
{
|
{
|
||||||
try { await _svc.DeleteProofAsync(id); return NoContent(); }
|
try { await _svc.DeleteProofAsync(id); return NoContent(); }
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
|
using ROLAC.API.DTOs.Payee;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/payee-1099")]
|
||||||
|
[HasPermission(Modules.Form1099, PermissionActions.Read)]
|
||||||
|
public class Payee1099Controller : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IPayee1099Service _svc;
|
||||||
|
public Payee1099Controller(IPayee1099Service svc) => _svc = svc;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
||||||
|
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
=> await _svc.GetByIdAsync(id) is { } dto ? Ok(dto) : NotFound();
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.Form1099, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> Create([FromBody] SavePayee1099Request r)
|
||||||
|
=> Ok(new { id = await _svc.CreateAsync(r) });
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
[HasPermission(Modules.Form1099, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> Update(int id, [FromBody] SavePayee1099Request r)
|
||||||
|
{ await _svc.UpdateAsync(id, r); return NoContent(); }
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
[HasPermission(Modules.Form1099, PermissionActions.Delete)]
|
||||||
|
public async Task<IActionResult> Delete(int id)
|
||||||
|
{ await _svc.DeleteAsync(id); return NoContent(); }
|
||||||
|
|
||||||
|
// Full TIN reveal is gated on Write (a stronger right than Read).
|
||||||
|
[HttpGet("{id:int}/tin")]
|
||||||
|
[HasPermission(Modules.Form1099, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> RevealTin(int id)
|
||||||
|
=> Ok(new { tin = await _svc.RevealTinAsync(id) });
|
||||||
|
|
||||||
|
// Mirrors the expense-receipt upload: multipart form file, size-limited, type-checked.
|
||||||
|
[HttpPost("{id:int}/w9")]
|
||||||
|
[HasPermission(Modules.Form1099, PermissionActions.Write)]
|
||||||
|
[RequestSizeLimit(10_485_760)]
|
||||||
|
public async Task<IActionResult> UploadW9(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.SaveW9Async(id, stream, file.FileName);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Class-level Read gate covers viewing the stored W-9 (mirrors the receipt GET).
|
||||||
|
[HttpGet("{id:int}/w9")]
|
||||||
|
public async Task<IActionResult> GetW9(int id)
|
||||||
|
{
|
||||||
|
var result = await _svc.OpenW9Async(id);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
return File(result.Value.stream, result.Value.contentType);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,105 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
|
using ROLAC.API.DTOs.Settings;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
using ROLAC.API.Services.Notifications;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Site-wide and notification (SMTP/Line) settings, surfaced by the Church Profile → Site /
|
||||||
|
/// Notification tabs. Gated by the <c>Settings</c> permission module (super_admin bypasses).
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/settings")]
|
||||||
|
[Authorize]
|
||||||
|
public class SettingsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ISettingsService _settings;
|
||||||
|
private readonly IEmailService _email;
|
||||||
|
private readonly ILineNotificationService _line;
|
||||||
|
private readonly CurrentUserAccessor _currentUser;
|
||||||
|
|
||||||
|
public SettingsController(
|
||||||
|
ISettingsService settings,
|
||||||
|
IEmailService email,
|
||||||
|
ILineNotificationService line,
|
||||||
|
CurrentUserAccessor currentUser)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
_email = email;
|
||||||
|
_line = line;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Site settings ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpGet("site")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Read)]
|
||||||
|
public async Task<IActionResult> GetSite() => Ok(await _settings.GetSiteAsync());
|
||||||
|
|
||||||
|
[HttpPut("site")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> UpdateSite([FromBody] UpdateSiteSettingRequest request)
|
||||||
|
{
|
||||||
|
await _settings.UpdateSiteAsync(request);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notification settings ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpGet("notification")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Read)]
|
||||||
|
public async Task<IActionResult> GetNotification()
|
||||||
|
{
|
||||||
|
var dto = await _settings.GetNotificationAsync();
|
||||||
|
dto.WebhookUrl = $"{Request.Scheme}://{Request.Host}/api/line/webhook";
|
||||||
|
return Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("notification")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> UpdateNotification([FromBody] UpdateNotificationSettingRequest request)
|
||||||
|
{
|
||||||
|
await _settings.UpdateNotificationAsync(request);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("notification/test-email")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> TestEmail([FromBody] TestEmailRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var to = string.IsNullOrWhiteSpace(request.ToAddress) ? _currentUser.Email : request.ToAddress;
|
||||||
|
if (string.IsNullOrWhiteSpace(to))
|
||||||
|
return BadRequest(new { message = "No recipient — provide an address or set an email on your account." });
|
||||||
|
|
||||||
|
var result = await _email.SendAsync(new EmailMessage(
|
||||||
|
MemberIds: Array.Empty<int>(),
|
||||||
|
Addresses: new[] { to },
|
||||||
|
Subject: "ROLAC test email / 測試郵件",
|
||||||
|
HtmlBody: "<p>This is a test email from ROLAC notification settings.</p>"
|
||||||
|
+ "<p>這是來自 ROLAC 通知設定的測試郵件。</p>",
|
||||||
|
SentByUserId: _currentUser.UserIdOrSystem), ct);
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("notification/test-line")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> TestLine([FromBody] TestLineRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (request.MemberId is null && request.GroupId is null)
|
||||||
|
return BadRequest(new { message = "Choose a bound member or group to receive the test." });
|
||||||
|
|
||||||
|
var result = await _line.SendLineAsync(
|
||||||
|
body: "ROLAC 測試訊息 / This is a test Line message from ROLAC.",
|
||||||
|
memberIds: request.MemberId is { } m ? new[] { m } : Array.Empty<int>(),
|
||||||
|
groupIds: request.GroupId is { } g ? new[] { g } : Array.Empty<int>(),
|
||||||
|
sentByUserId: _currentUser.UserIdOrSystem,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
|
using ROLAC.API.DTOs.Logging;
|
||||||
|
using ROLAC.API.Entities.Logging;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/system-logs")]
|
||||||
|
[Authorize]
|
||||||
|
public class SystemLogsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ISystemLogQueryService _svc;
|
||||||
|
public SystemLogsController(ISystemLogQueryService svc) => _svc = svc;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[HasPermission(Modules.SystemLogs, PermissionActions.Read)]
|
||||||
|
public async Task<IActionResult> GetPaged([FromQuery] SystemLogQuery query)
|
||||||
|
=> Ok(await _svc.GetPagedAsync(query));
|
||||||
|
|
||||||
|
[HttpGet("{id:long}")]
|
||||||
|
[HasPermission(Modules.SystemLogs, PermissionActions.Read)]
|
||||||
|
public async Task<IActionResult> GetById(long id)
|
||||||
|
{
|
||||||
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>All six severities, so the UI can offer every filter option regardless of data.</summary>
|
||||||
|
[HttpGet("levels")]
|
||||||
|
[HasPermission(Modules.SystemLogs, PermissionActions.Read)]
|
||||||
|
public IActionResult GetLevels() => Ok(Enum.GetNames<LogLevelEnum>());
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Users;
|
using ROLAC.API.DTOs.Users;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -7,7 +8,7 @@ namespace ROLAC.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/users")]
|
[Route("api/users")]
|
||||||
[Authorize(Roles = "super_admin")]
|
[Authorize]
|
||||||
public class UsersController : ControllerBase
|
public class UsersController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IUserManagementService _users;
|
private readonly IUserManagementService _users;
|
||||||
@@ -15,6 +16,7 @@ public class UsersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>GET /api/users?page=1&pageSize=20&search=Chris</summary>
|
/// <summary>GET /api/users?page=1&pageSize=20&search=Chris</summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetPaged(
|
public async Task<IActionResult> GetPaged(
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
[FromQuery] int pageSize = 20,
|
[FromQuery] int pageSize = 20,
|
||||||
@@ -23,6 +25,7 @@ public class UsersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>GET /api/users/{id}</summary>
|
/// <summary>GET /api/users/{id}</summary>
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetById(string id)
|
public async Task<IActionResult> GetById(string id)
|
||||||
{
|
{
|
||||||
var dto = await _users.GetByIdAsync(id);
|
var dto = await _users.GetByIdAsync(id);
|
||||||
@@ -34,6 +37,7 @@ public class UsersController : ControllerBase
|
|||||||
/// TempPassword is returned ONCE — show it to the admin and never log it.
|
/// TempPassword is returned ONCE — show it to the admin and never log it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateUserRequest request)
|
public async Task<IActionResult> Create([FromBody] CreateUserRequest request)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -49,6 +53,7 @@ public class UsersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>PUT /api/users/{id} — update email, roles, IsActive</summary>
|
/// <summary>PUT /api/users/{id} — update email, roles, IsActive</summary>
|
||||||
[HttpPut("{id}")]
|
[HttpPut("{id}")]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Update(string id, [FromBody] UpdateUserRequest request)
|
public async Task<IActionResult> Update(string id, [FromBody] UpdateUserRequest request)
|
||||||
{
|
{
|
||||||
try { await _users.UpdateAsync(id, request); return NoContent(); }
|
try { await _users.UpdateAsync(id, request); return NoContent(); }
|
||||||
@@ -58,6 +63,7 @@ public class UsersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>DELETE /api/users/{id} — deactivates account (IsActive=false), does not delete</summary>
|
/// <summary>DELETE /api/users/{id} — deactivates account (IsActive=false), does not delete</summary>
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> Deactivate(string id)
|
public async Task<IActionResult> Deactivate(string id)
|
||||||
{
|
{
|
||||||
try { await _users.DeactivateAsync(id); return NoContent(); }
|
try { await _users.DeactivateAsync(id); return NoContent(); }
|
||||||
@@ -66,6 +72,7 @@ public class UsersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>POST /api/users/{id}/reset-password — returns new temp password</summary>
|
/// <summary>POST /api/users/{id}/reset-password — returns new temp password</summary>
|
||||||
[HttpPost("{id}/reset-password")]
|
[HttpPost("{id}/reset-password")]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> ResetPassword(string id)
|
public async Task<IActionResult> ResetPassword(string id)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace ROLAC.API.DTOs.Auth;
|
||||||
|
|
||||||
|
public class ChangePasswordRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string CurrentPassword { get; set; } = null!;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MinLength(8)]
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string NewPassword { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ROLAC.API.DTOs.Permissions;
|
||||||
|
|
||||||
namespace ROLAC.API.DTOs.Auth;
|
namespace ROLAC.API.DTOs.Auth;
|
||||||
|
|
||||||
public class LoginResponse
|
public class LoginResponse
|
||||||
@@ -17,4 +19,28 @@ public class UserInfo
|
|||||||
public string Email { get; set; } = null!;
|
public string Email { get; set; } = null!;
|
||||||
public IList<string> Roles { get; set; } = [];
|
public IList<string> Roles { get; set; } = [];
|
||||||
public string LanguagePreference { get; set; } = "en";
|
public string LanguagePreference { get; set; } = "en";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Effective permissions (union across the user's roles), keyed by module name.
|
||||||
|
/// Lets the SPA hide nav/buttons. Authoritative enforcement is server-side.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, ModuleActions> Permissions { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The church member linked to this login account, or null for admin-only
|
||||||
|
/// accounts (no MemberId) and accounts whose member record was deleted.
|
||||||
|
/// Lets the SPA greet the user by their real name.
|
||||||
|
/// </summary>
|
||||||
|
public MemberInfo? MemberInfo { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Minimal member identity for greeting the signed-in user.</summary>
|
||||||
|
public class MemberInfo
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? NickName { get; set; }
|
||||||
|
public string FirstName_en { get; set; } = "";
|
||||||
|
public string LastName_en { get; set; } = "";
|
||||||
|
public string? FirstName_zh { get; set; }
|
||||||
|
public string? LastName_zh { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ public class ChurchProfileDto
|
|||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
|
public string? NameZh { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Website { get; set; }
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
public string? City { get; set; }
|
public string? City { get; set; }
|
||||||
public string? State { get; set; }
|
public string? State { get; set; }
|
||||||
@@ -12,12 +16,22 @@ public class ChurchProfileDto
|
|||||||
public string? BankName { get; set; }
|
public string? BankName { get; set; }
|
||||||
public string? BankAccountNumber { get; set; }
|
public string? BankAccountNumber { get; set; }
|
||||||
public string? BankRoutingNumber { get; set; }
|
public string? BankRoutingNumber { get; set; }
|
||||||
|
public string? PayerEin { get; set; }
|
||||||
public int NextCheckNumber { get; set; }
|
public int NextCheckNumber { get; set; }
|
||||||
|
public string AiProvider { get; set; } = "Claude";
|
||||||
|
public string? ClaudeModel { get; set; }
|
||||||
|
public string? ClaudeApiKeyMasked { get; set; }
|
||||||
|
public string? GeminiModel { get; set; }
|
||||||
|
public string? GeminiApiKeyMasked { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateChurchProfileRequest
|
public class UpdateChurchProfileRequest
|
||||||
{
|
{
|
||||||
[Required, MaxLength(200)] public string Name { get; set; } = "";
|
[Required, MaxLength(200)] public string Name { get; set; } = "";
|
||||||
|
[MaxLength(200)] public string? NameZh { get; set; }
|
||||||
|
[MaxLength(50)] public string? Phone { get; set; }
|
||||||
|
[MaxLength(200), EmailAddress] public string? Email { get; set; }
|
||||||
|
[MaxLength(300)] public string? Website { get; set; }
|
||||||
[MaxLength(500)] public string? Address { get; set; }
|
[MaxLength(500)] public string? Address { get; set; }
|
||||||
[MaxLength(100)] public string? City { get; set; }
|
[MaxLength(100)] public string? City { get; set; }
|
||||||
[MaxLength(50)] public string? State { get; set; }
|
[MaxLength(50)] public string? State { get; set; }
|
||||||
@@ -25,5 +39,11 @@ public class UpdateChurchProfileRequest
|
|||||||
[MaxLength(200)] public string? BankName { get; set; }
|
[MaxLength(200)] public string? BankName { get; set; }
|
||||||
[MaxLength(50)] public string? BankAccountNumber { get; set; }
|
[MaxLength(50)] public string? BankAccountNumber { get; set; }
|
||||||
[MaxLength(50)] public string? BankRoutingNumber { get; set; }
|
[MaxLength(50)] public string? BankRoutingNumber { get; set; }
|
||||||
|
[MaxLength(20)] public string? PayerEin { get; set; }
|
||||||
[Range(1, int.MaxValue)] public int NextCheckNumber { get; set; }
|
[Range(1, int.MaxValue)] public int NextCheckNumber { get; set; }
|
||||||
|
[MaxLength(20)] public string AiProvider { get; set; } = "Claude";
|
||||||
|
[MaxLength(100)] public string? ClaudeModel { get; set; }
|
||||||
|
[MaxLength(500)] public string? ClaudeApiKey { get; set; } // null/blank = leave unchanged
|
||||||
|
[MaxLength(100)] public string? GeminiModel { get; set; }
|
||||||
|
[MaxLength(500)] public string? GeminiApiKey { get; set; } // null/blank = leave unchanged
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Expense;
|
||||||
|
|
||||||
|
/// <summary>Request body for the expense AI assist endpoint.</summary>
|
||||||
|
public class ExpenseAiAssistRequest
|
||||||
|
{
|
||||||
|
/// <summary>The user's free-text expense description (typically Chinese).</summary>
|
||||||
|
[Required] public string Text { get; set; } = "";
|
||||||
|
/// <summary>The expense amount, used as a hint when classifying the category.</summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AI suggestion for an expense: an English translation of the description plus a proposed
|
||||||
|
/// major category (大項) and sub-category (系項). Category ids are null when the model could
|
||||||
|
/// not confidently classify or returned an id outside the live catalog.
|
||||||
|
/// </summary>
|
||||||
|
public class ExpenseAiSuggestion
|
||||||
|
{
|
||||||
|
public string? EnglishDescription { get; set; }
|
||||||
|
/// <summary>Typo-corrected, refined Traditional Chinese description.</summary>
|
||||||
|
public string? ChineseDescription { get; set; }
|
||||||
|
public int? GroupId { get; set; }
|
||||||
|
public int? SubCategoryId { get; set; }
|
||||||
|
/// <summary>Bilingual label of the suggested group, e.g. "Consumables / 消耗品".</summary>
|
||||||
|
public string? GroupLabel { get; set; }
|
||||||
|
/// <summary>Bilingual label of the suggested sub-category, e.g. "Batteries / 電池".</summary>
|
||||||
|
public string? SubLabel { get; set; }
|
||||||
|
/// <summary>Model self-reported confidence in the classification, 0..1.</summary>
|
||||||
|
public double Confidence { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request body for the expense-category AI assist endpoint: refine the name, translate to English,
|
||||||
|
/// and suggest a Form 990 line for an expense category (大項/小項) being defined or edited.
|
||||||
|
/// </summary>
|
||||||
|
public class ExpenseCategoryAiRequest
|
||||||
|
{
|
||||||
|
/// <summary>The user-typed Chinese name (the primary input).</summary>
|
||||||
|
public string Name_zh { get; set; } = "";
|
||||||
|
/// <summary>The English name, if already typed (extra context for the model).</summary>
|
||||||
|
public string? Name_en { get; set; }
|
||||||
|
/// <summary>"group" (大項) or "sub" (小項); selects the prompt framing.</summary>
|
||||||
|
public string Level { get; set; } = "group";
|
||||||
|
/// <summary>For a sub-category: the parent group's bilingual name, used for context.</summary>
|
||||||
|
public string? ParentGroupName { get; set; }
|
||||||
|
/// <summary>For a sub-category: the parent group's mapped Form 990 line id, used to bias the choice.</summary>
|
||||||
|
public int? ParentForm990LineId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AI suggestion for an expense category: a refined Chinese name, an English translation, and a
|
||||||
|
/// proposed Form 990 line. Line fields are null when the model returned an id outside the live catalog.
|
||||||
|
/// </summary>
|
||||||
|
public class CategoryAiSuggestion
|
||||||
|
{
|
||||||
|
/// <summary>Typo-corrected, refined Traditional Chinese name.</summary>
|
||||||
|
public string? ChineseName { get; set; }
|
||||||
|
public string? EnglishName { get; set; }
|
||||||
|
public int? Form990LineId { get; set; }
|
||||||
|
/// <summary>Bilingual label of the suggested line, e.g. "16 — Occupancy / 場地".</summary>
|
||||||
|
public string? Form990LineLabel { get; set; }
|
||||||
|
/// <summary>Model self-reported confidence in the mapping, 0..1.</summary>
|
||||||
|
public double Confidence { get; set; }
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@ public class ExpenseSubCategoryDto
|
|||||||
public string? Name_zh { get; set; }
|
public string? Name_zh { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
|
public int? Form990LineId { get; set; }
|
||||||
|
public string? Form990LineCode { get; set; }
|
||||||
|
public int? Form1099BoxId { get; set; }
|
||||||
|
public string? Form1099BoxCode { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ExpenseCategoryGroupDto
|
public class ExpenseCategoryGroupDto
|
||||||
@@ -18,6 +22,10 @@ public class ExpenseCategoryGroupDto
|
|||||||
public string? Name_zh { get; set; }
|
public string? Name_zh { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
|
public int? Form990LineId { get; set; }
|
||||||
|
public string? Form990LineCode { get; set; }
|
||||||
|
public int? Form1099BoxId { get; set; }
|
||||||
|
public string? Form1099BoxCode { get; set; }
|
||||||
public List<ExpenseSubCategoryDto> SubCategories { get; set; } = [];
|
public List<ExpenseSubCategoryDto> SubCategories { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +34,8 @@ public class CreateExpenseGroupRequest
|
|||||||
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
||||||
[MaxLength(200)] public string? Name_zh { get; set; }
|
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
|
public int? Form990LineId { get; set; }
|
||||||
|
public int? Form1099BoxId { get; set; }
|
||||||
}
|
}
|
||||||
public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest
|
public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest
|
||||||
{
|
{
|
||||||
@@ -38,6 +48,8 @@ public class CreateExpenseSubCategoryRequest
|
|||||||
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
||||||
[MaxLength(200)] public string? Name_zh { get; set; }
|
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
|
public int? Form990LineId { get; set; }
|
||||||
|
public int? Form1099BoxId { get; set; }
|
||||||
}
|
}
|
||||||
public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest
|
public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,50 +1,73 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
namespace ROLAC.API.DTOs.Expense;
|
namespace ROLAC.API.DTOs.Expense;
|
||||||
|
|
||||||
|
public class ExpenseLineItemDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int CategoryGroupId { get; set; }
|
||||||
|
public string CategoryGroupName { get; set; } = "";
|
||||||
|
public int SubCategoryId { get; set; }
|
||||||
|
public string SubCategoryName { get; set; } = "";
|
||||||
|
public string? FunctionalClass { get; set; }
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class ExpenseListItemDto
|
public class ExpenseListItemDto
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Type { get; set; } = "";
|
public string Type { get; set; } = "";
|
||||||
public string Status { get; set; } = "";
|
public string Status { get; set; } = "";
|
||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; } // header total = sum of line amounts
|
||||||
public string Description { get; set; } = "";
|
public string Description { get; set; } = "";
|
||||||
public int MinistryId { get; set; }
|
public int MinistryId { get; set; }
|
||||||
public string MinistryName { get; set; } = "";
|
public string MinistryName { get; set; } = "";
|
||||||
public int CategoryGroupId { get; set; }
|
public int LineCount { get; set; }
|
||||||
public string CategoryGroupName { get; set; } = "";
|
public string PrimaryCategoryName { get; set; } = ""; // first line's category (list hint; full breakdown via detail)
|
||||||
public int SubCategoryId { get; set; }
|
|
||||||
public string SubCategoryName { get; set; } = "";
|
|
||||||
public string? VendorName { get; set; }
|
public string? VendorName { get; set; }
|
||||||
public int? MemberId { get; set; }
|
public int? MemberId { get; set; }
|
||||||
public string? MemberName { get; set; }
|
public string? MemberName { get; set; } // legal name "FirstName_en LastName_en" (used on the printed check)
|
||||||
|
public string? MemberNickName { get; set; } // "NickName LastName_en"; null when the member has no distinct nickname
|
||||||
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
|
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
|
||||||
public bool HasReceipt { get; set; }
|
public bool HasReceipt { get; set; }
|
||||||
public string? CheckNumber { get; set; }
|
public string? CheckNumber { get; set; }
|
||||||
|
// Review outcome — surfaced on the list so the Status column can show "Approved/Rejected by X · date".
|
||||||
|
public string? ReviewedByName { get; set; } // resolved Member full name, email fallback
|
||||||
|
public DateTimeOffset? ReviewedAt { get; set; }
|
||||||
|
public string? ReviewNotes { get; set; } // reject reason (or approval note)
|
||||||
|
public int? PayeeId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ExpenseDto : ExpenseListItemDto
|
public class ExpenseDto : ExpenseListItemDto
|
||||||
{
|
{
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public string? ReviewNotes { get; set; }
|
|
||||||
public string? SubmittedBy { get; set; }
|
public string? SubmittedBy { get; set; }
|
||||||
public DateTimeOffset? SubmittedAt { get; set; }
|
public DateTimeOffset? SubmittedAt { get; set; }
|
||||||
public DateTimeOffset? ReviewedAt { get; set; }
|
|
||||||
public DateTimeOffset? PaidAt { get; set; }
|
public DateTimeOffset? PaidAt { get; set; }
|
||||||
|
public List<ExpenseLineItemDto> Lines { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExpenseLineInput
|
||||||
|
{
|
||||||
|
[Required] public int CategoryGroupId { get; set; }
|
||||||
|
[Required] public int SubCategoryId { get; set; }
|
||||||
|
[Range(0.01, 9_999_999)] public decimal Amount { get; set; }
|
||||||
|
[MaxLength(20)] public string? FunctionalClass { get; set; }
|
||||||
|
[MaxLength(500)] public string? Description { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CreateExpenseRequest
|
public class CreateExpenseRequest
|
||||||
{
|
{
|
||||||
[Required] public string Type { get; set; } = "StaffReimbursement"; // VendorPayment|StaffReimbursement
|
[Required] public string Type { get; set; } = "StaffReimbursement"; // VendorPayment|StaffReimbursement
|
||||||
[Required] public int MinistryId { get; set; }
|
[Required] public int MinistryId { get; set; }
|
||||||
[Required] public int CategoryGroupId { get; set; }
|
[Required, MinLength(1)] public List<ExpenseLineInput> Lines { get; set; } = new();
|
||||||
[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; } = "";
|
[Required, MaxLength(500)] public string Description { get; set; } = "";
|
||||||
[MaxLength(200)] public string? VendorName { get; set; }
|
[MaxLength(200)] public string? VendorName { get; set; }
|
||||||
public int? MemberId { get; set; } // ignored for self-service (server uses caller)
|
public int? MemberId { get; set; } // ignored for self-service (server uses caller)
|
||||||
[MaxLength(50)] public string? CheckNumber { get; set; }
|
[MaxLength(50)] public string? CheckNumber { get; set; }
|
||||||
[Required] public DateOnly ExpenseDate { get; set; }
|
[Required] public DateOnly ExpenseDate { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
public int? PayeeId { get; set; }
|
||||||
}
|
}
|
||||||
public class UpdateExpenseRequest : CreateExpenseRequest { }
|
public class UpdateExpenseRequest : CreateExpenseRequest { }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Expense;
|
||||||
|
|
||||||
|
public class ExpenseSnapshotLineDto
|
||||||
|
{
|
||||||
|
public int CategoryGroupId { get; set; }
|
||||||
|
public string CategoryGroupName { get; set; } = "";
|
||||||
|
public int SubCategoryId { get; set; }
|
||||||
|
public string SubCategoryName { get; set; } = "";
|
||||||
|
public string? FunctionalClass { get; set; }
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExpenseSnapshotDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public int MinistryId { get; set; }
|
||||||
|
public string MinistryName { get; set; } = "";
|
||||||
|
public string Description { get; set; } = "";
|
||||||
|
public string? VendorName { get; set; }
|
||||||
|
public string? CheckNumber { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public decimal TotalAmount { get; set; } // sum of line amounts (list hint)
|
||||||
|
public int LineCount { get; set; }
|
||||||
|
public string? CreatedByName { get; set; } // resolved Member full name, email fallback
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
public List<ExpenseSnapshotLineDto> Lines { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateExpenseSnapshotRequest
|
||||||
|
{
|
||||||
|
[Required, MaxLength(150)] public string Name { get; set; } = "";
|
||||||
|
[Required] public int MinistryId { get; set; }
|
||||||
|
[Required, MinLength(1)] public List<ExpenseLineInput> Lines { get; set; } = new();
|
||||||
|
[Required, MaxLength(500)] public string Description { get; set; } = "";
|
||||||
|
[MaxLength(200)] public string? VendorName { get; set; }
|
||||||
|
[MaxLength(50)] public string? CheckNumber { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
public class UpdateExpenseSnapshotRequest : CreateExpenseSnapshotRequest { }
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Finance;
|
||||||
|
|
||||||
|
public class Form1099BoxDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string BoxCode { get; set; } = "";
|
||||||
|
public string Name_en { get; set; } = "";
|
||||||
|
public string? Name_zh { get; set; }
|
||||||
|
public string FormType { get; set; } = "";
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Form1099RecipientRowDto
|
||||||
|
{
|
||||||
|
public int PayeeId { get; set; }
|
||||||
|
public string LegalName { get; set; } = "";
|
||||||
|
public string? TinLast4 { get; set; }
|
||||||
|
public string W9Status { get; set; } = "";
|
||||||
|
public decimal NecTotal { get; set; }
|
||||||
|
public decimal RentsTotal { get; set; }
|
||||||
|
public decimal GrandTotal { get; set; }
|
||||||
|
public bool MeetsThreshold { get; set; }
|
||||||
|
public bool W9Missing { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Form1099SummaryDto
|
||||||
|
{
|
||||||
|
public int TaxYear { get; set; }
|
||||||
|
public List<Form1099RecipientRowDto> Rows { get; set; } = [];
|
||||||
|
public decimal TotalReportable { get; set; }
|
||||||
|
public int RecipientsAtThreshold { get; set; }
|
||||||
|
public int RecipientsMissingW9 { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Form1099PaymentDto
|
||||||
|
{
|
||||||
|
public string PaidDate { get; set; } = "";
|
||||||
|
public string Description { get; set; } = "";
|
||||||
|
public string CategoryName { get; set; } = "";
|
||||||
|
public string BoxCode { get; set; } = "";
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Form1099RecipientDetailDto
|
||||||
|
{
|
||||||
|
public int PayeeId { get; set; }
|
||||||
|
public string LegalName { get; set; } = "";
|
||||||
|
public string? TinLast4 { get; set; }
|
||||||
|
public string W9Status { get; set; } = "";
|
||||||
|
public int TaxYear { get; set; }
|
||||||
|
public List<Form1099PaymentDto> Payments { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Finance;
|
||||||
|
|
||||||
|
/// <summary>One Part IX row: a 990 line split across the three functional columns.</summary>
|
||||||
|
public class FunctionalExpenseRowDto
|
||||||
|
{
|
||||||
|
public string LineCode { get; set; } = "";
|
||||||
|
public string Name_en { get; set; } = "";
|
||||||
|
public string? Name_zh { get; set; }
|
||||||
|
public decimal Program { get; set; }
|
||||||
|
public decimal ManagementGeneral { get; set; }
|
||||||
|
public decimal Fundraising { get; set; }
|
||||||
|
public decimal Total { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The full Part IX Statement of Functional Expenses for a date range.</summary>
|
||||||
|
public class FunctionalExpenseStatementDto
|
||||||
|
{
|
||||||
|
public List<FunctionalExpenseRowDto> Rows { get; set; } = [];
|
||||||
|
public decimal ProgramTotal { get; set; }
|
||||||
|
public decimal ManagementGeneralTotal { get; set; }
|
||||||
|
public decimal FundraisingTotal { get; set; }
|
||||||
|
public decimal GrandTotal { get; set; }
|
||||||
|
/// <summary>Expenses with no explicit 990 mapping (counted under line 24). Prompts mapping cleanup.</summary>
|
||||||
|
public int UnmappedExpenseCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A single IRS Form 990 expense line from the catalog (used to populate mapping dropdowns).</summary>
|
||||||
|
public class Form990ExpenseLineDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string LineCode { get; set; } = "";
|
||||||
|
public string Name_en { get; set; } = "";
|
||||||
|
public string? Name_zh { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
@@ -9,4 +9,5 @@ public class MemberTypeaheadDto
|
|||||||
public string? NickName { get; set; }
|
public string? NickName { get; set; }
|
||||||
public string FirstName_en { get; set; } = "";
|
public string FirstName_en { get; set; } = "";
|
||||||
public string LastName_en { get; set; } = "";
|
public string LastName_en { get; set; } = "";
|
||||||
|
public string? Entity { get; set; } // company / business name (公司行號), if any
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ public class OfferingSessionListItemDto
|
|||||||
public decimal Difference { get; set; }
|
public decimal Difference { get; set; }
|
||||||
public int LineCount { get; set; }
|
public int LineCount { get; set; }
|
||||||
public bool HasProof { get; set; }
|
public bool HasProof { get; set; }
|
||||||
|
public int? SundayAttendanceCount { get; set; } // null = no attendance recorded for the date
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,5 +11,6 @@ public class QuickAddMemberRequest
|
|||||||
[MaxLength(100)] public string? NickName { get; set; }
|
[MaxLength(100)] public string? NickName { get; set; }
|
||||||
[MaxLength(100)] public string? FirstName_zh { get; set; }
|
[MaxLength(100)] public string? FirstName_zh { get; set; }
|
||||||
[MaxLength(100)] public string? LastName_zh { get; set; }
|
[MaxLength(100)] public string? LastName_zh { get; set; }
|
||||||
|
[MaxLength(200)] public string? Entity { get; set; }
|
||||||
[MaxLength(30)] public string? PhoneCell { get; set; }
|
[MaxLength(30)] public string? PhoneCell { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace ROLAC.API.DTOs.Invitations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Admin request to generate a first-login invitation link for a member. If the member has no
|
||||||
|
/// account yet, one is auto-created (no password) using <see cref="Email"/> or the member's email.
|
||||||
|
/// </summary>
|
||||||
|
public class CreateInvitationRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public int MemberId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional override for the login email when the member has none on file.</summary>
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Roles to assign when an account is created. Defaults to ["member"].</summary>
|
||||||
|
public List<string>? Roles { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Result of generating an invitation — the raw token is returned ONCE.</summary>
|
||||||
|
public class CreateInvitationResult
|
||||||
|
{
|
||||||
|
public string Token { get; set; } = null!;
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Admin request to e-mail an already-generated invitation link to the member.</summary>
|
||||||
|
public class SendInvitationRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public int MemberId { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string Link { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Public result describing whether an invitation token can still be used.</summary>
|
||||||
|
public class ValidateInvitationResult
|
||||||
|
{
|
||||||
|
public bool Valid { get; set; }
|
||||||
|
public bool Expired { get; set; }
|
||||||
|
public string? MemberName { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Public request to consume an invitation and set the account password.</summary>
|
||||||
|
public class AcceptInvitationRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string Token { get; set; } = null!;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[StringLength(128, MinimumLength = 8)]
|
||||||
|
public string NewPassword { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -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,9 @@
|
|||||||
|
namespace ROLAC.API.DTOs.MealAttendance;
|
||||||
|
|
||||||
|
/// <summary>Absolute head-counts to write for one Sunday, from the back-office editor.</summary>
|
||||||
|
public class SetAttendanceRequest
|
||||||
|
{
|
||||||
|
public int Adult { get; set; }
|
||||||
|
public int Youth { get; set; }
|
||||||
|
public int Kid { get; set; }
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ public class CreateMemberRequest
|
|||||||
[MaxLength(100)] public string? NickName { get; set; }
|
[MaxLength(100)] public string? NickName { get; set; }
|
||||||
[MaxLength(100)] public string? FirstName_zh { get; set; }
|
[MaxLength(100)] public string? FirstName_zh { get; set; }
|
||||||
[MaxLength(100)] public string? LastName_zh { get; set; }
|
[MaxLength(100)] public string? LastName_zh { get; set; }
|
||||||
|
[MaxLength(200)] public string? Entity { get; set; }
|
||||||
[MaxLength(10)] public string? Gender { get; set; }
|
[MaxLength(10)] public string? Gender { get; set; }
|
||||||
public DateOnly? DateOfBirth { get; set; }
|
public DateOnly? DateOfBirth { get; set; }
|
||||||
public DateOnly? BaptismDate { get; set; }
|
public DateOnly? BaptismDate { get; set; }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public class MemberListItemDto
|
|||||||
public string? NickName { get; set; }
|
public string? NickName { get; set; }
|
||||||
public string? FirstName_zh { get; set; }
|
public string? FirstName_zh { get; set; }
|
||||||
public string? LastName_zh { get; set; }
|
public string? LastName_zh { get; set; }
|
||||||
|
public string? Entity { get; set; }
|
||||||
public string Status { get; set; } = "";
|
public string Status { get; set; } = "";
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? PhoneCell { get; set; }
|
public string? PhoneCell { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Ministry;
|
||||||
|
|
||||||
|
public class CreateMinistryRequest
|
||||||
|
{
|
||||||
|
[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; }
|
||||||
|
[MaxLength(20)] public string? DefaultFunctionalClass { get; set; }
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ public class MinistryDto
|
|||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Name_en { get; set; } = "";
|
public string Name_en { get; set; } = "";
|
||||||
public string? Name_zh { get; set; }
|
public string? Name_zh { get; set; }
|
||||||
|
public string? Description_en { get; set; }
|
||||||
|
public string? Description_zh { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
|
public string DefaultFunctionalClass { get; set; } = "Program";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Ministry;
|
||||||
|
|
||||||
|
public class UpdateMinistryRequest
|
||||||
|
{
|
||||||
|
[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; }
|
||||||
|
[MaxLength(20)] public string? DefaultFunctionalClass { 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,54 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Payee;
|
||||||
|
|
||||||
|
public class Payee1099ListItemDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string LegalName { get; set; } = "";
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
|
public int? MemberId { get; set; }
|
||||||
|
public string? MemberName { get; set; }
|
||||||
|
public string TaxClassification { get; set; } = "";
|
||||||
|
public bool Is1099Tracked { get; set; }
|
||||||
|
public string? TinType { get; set; }
|
||||||
|
public string? TinLast4 { get; set; }
|
||||||
|
public string W9Status { get; set; } = "";
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Payee1099Dto : Payee1099ListItemDto
|
||||||
|
{
|
||||||
|
public string? AddressLine1 { get; set; }
|
||||||
|
public string? AddressLine2 { get; set; }
|
||||||
|
public string? City { get; set; }
|
||||||
|
public string? State { get; set; }
|
||||||
|
public string? Zip { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? W9ReceivedDate { get; set; }
|
||||||
|
public bool HasW9Document { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SavePayee1099Request
|
||||||
|
{
|
||||||
|
[Required, MaxLength(200)] public string LegalName { get; set; } = "";
|
||||||
|
[MaxLength(200)] public string? DisplayName { get; set; }
|
||||||
|
public int? MemberId { get; set; }
|
||||||
|
[Required, MaxLength(40)] public string TaxClassification { get; set; } = "Individual";
|
||||||
|
public bool Is1099Tracked { get; set; } = true;
|
||||||
|
[MaxLength(10)] public string? TinType { get; set; }
|
||||||
|
/// <summary>Plain TIN; null = leave unchanged on update. Encrypted server-side.</summary>
|
||||||
|
public string? Tin { get; set; }
|
||||||
|
[MaxLength(100)] public string? AddressLine1 { get; set; }
|
||||||
|
[MaxLength(100)] public string? AddressLine2 { get; set; }
|
||||||
|
[MaxLength(60)] public string? City { get; set; }
|
||||||
|
[MaxLength(2)] public string? State { get; set; }
|
||||||
|
[MaxLength(10)] public string? Zip { get; set; }
|
||||||
|
[MaxLength(120)] public string? Email { get; set; }
|
||||||
|
[MaxLength(40)] public string? Phone { get; set; }
|
||||||
|
[MaxLength(20)] public string W9Status { get; set; } = "Missing";
|
||||||
|
public DateOnly? W9ReceivedDate { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
@@ -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,80 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Settings;
|
||||||
|
|
||||||
|
// ── Site settings ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public class SiteSettingDto
|
||||||
|
{
|
||||||
|
public string SiteTitle { get; set; } = "";
|
||||||
|
public string? SiteTitleZh { get; set; }
|
||||||
|
public string DefaultLanguage { get; set; } = "en";
|
||||||
|
public string TimeZone { get; set; } = "";
|
||||||
|
public string DateFormat { get; set; } = "";
|
||||||
|
public string Currency { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateSiteSettingRequest
|
||||||
|
{
|
||||||
|
[Required, MaxLength(200)] public string SiteTitle { get; set; } = "";
|
||||||
|
[MaxLength(200)] public string? SiteTitleZh { get; set; }
|
||||||
|
[Required, MaxLength(10)] public string DefaultLanguage { get; set; } = "en";
|
||||||
|
[Required, MaxLength(100)] public string TimeZone { get; set; } = "";
|
||||||
|
[Required, MaxLength(50)] public string DateFormat { get; set; } = "";
|
||||||
|
[Required, MaxLength(10)] public string Currency { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notification settings ──────────────────────────────────────────────────
|
||||||
|
// Secrets are never returned. The DTO exposes only whether each secret is configured; the UI
|
||||||
|
// shows a write-only field where a blank value on update means "keep the stored secret".
|
||||||
|
|
||||||
|
public class NotificationSettingDto
|
||||||
|
{
|
||||||
|
public bool EnableEmail { get; set; }
|
||||||
|
public string SmtpHost { get; set; } = "";
|
||||||
|
public int SmtpPort { get; set; }
|
||||||
|
public bool SmtpUseSsl { get; set; }
|
||||||
|
public string SmtpUser { get; set; } = "";
|
||||||
|
public string FromAddress { get; set; } = "";
|
||||||
|
public string FromName { get; set; } = "";
|
||||||
|
public bool HasSmtpPassword { get; set; }
|
||||||
|
|
||||||
|
public bool EnableLine { get; set; }
|
||||||
|
public bool HasLineChannelAccessToken { get; set; }
|
||||||
|
public bool HasLineChannelSecret { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Read-only webhook URL to register in the Line console (derived from the request).</summary>
|
||||||
|
public string WebhookUrl { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateNotificationSettingRequest
|
||||||
|
{
|
||||||
|
public bool EnableEmail { get; set; }
|
||||||
|
[MaxLength(200)] public string SmtpHost { get; set; } = "";
|
||||||
|
[Range(0, 65535)] public int SmtpPort { get; set; } = 587;
|
||||||
|
public bool SmtpUseSsl { get; set; } = true;
|
||||||
|
[MaxLength(200)] public string SmtpUser { get; set; } = "";
|
||||||
|
[MaxLength(200)] public string? FromAddress { get; set; }
|
||||||
|
[MaxLength(200)] public string? FromName { get; set; }
|
||||||
|
/// <summary>Blank = keep the stored password unchanged.</summary>
|
||||||
|
[MaxLength(500)] public string? SmtpPassword { get; set; }
|
||||||
|
|
||||||
|
public bool EnableLine { get; set; }
|
||||||
|
/// <summary>Blank = keep the stored token unchanged.</summary>
|
||||||
|
[MaxLength(500)] public string? LineChannelAccessToken { get; set; }
|
||||||
|
/// <summary>Blank = keep the stored secret unchanged.</summary>
|
||||||
|
[MaxLength(200)] public string? LineChannelSecret { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test-send requests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public class TestEmailRequest
|
||||||
|
{
|
||||||
|
/// <summary>Optional override; defaults to the current user's email when omitted.</summary>
|
||||||
|
[MaxLength(200), EmailAddress] public string? ToAddress { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TestLineRequest
|
||||||
|
{
|
||||||
|
public int? MemberId { get; set; }
|
||||||
|
public int? GroupId { get; set; }
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data.Logging;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Entities.Notifications;
|
||||||
|
|
||||||
namespace ROLAC.API.Data;
|
namespace ROLAC.API.Data;
|
||||||
|
|
||||||
@@ -9,6 +11,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||||
|
|
||||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
|
public DbSet<UserInvitation> UserInvitations => Set<UserInvitation>();
|
||||||
public DbSet<Member> Members => Set<Member>();
|
public DbSet<Member> Members => Set<Member>();
|
||||||
public DbSet<FamilyUnit> FamilyUnits => Set<FamilyUnit>();
|
public DbSet<FamilyUnit> FamilyUnits => Set<FamilyUnit>();
|
||||||
public DbSet<GivingCategory> GivingCategories => Set<GivingCategory>();
|
public DbSet<GivingCategory> GivingCategories => Set<GivingCategory>();
|
||||||
@@ -17,12 +20,27 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
public DbSet<Ministry> Ministries => Set<Ministry>();
|
public DbSet<Ministry> Ministries => Set<Ministry>();
|
||||||
public DbSet<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>();
|
public DbSet<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>();
|
||||||
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
|
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
|
||||||
|
public DbSet<Form990ExpenseLine> Form990ExpenseLines => Set<Form990ExpenseLine>();
|
||||||
|
public DbSet<Payee1099> Payee1099s => Set<Payee1099>();
|
||||||
|
public DbSet<Form1099Box> Form1099Boxes => Set<Form1099Box>();
|
||||||
public DbSet<Expense> Expenses => Set<Expense>();
|
public DbSet<Expense> Expenses => Set<Expense>();
|
||||||
|
public DbSet<ExpenseLine> ExpenseLines => Set<ExpenseLine>();
|
||||||
|
public DbSet<ExpenseSnapshot> ExpenseSnapshots => Set<ExpenseSnapshot>();
|
||||||
|
public DbSet<ExpenseSnapshotLine> ExpenseSnapshotLines => Set<ExpenseSnapshotLine>();
|
||||||
public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>();
|
public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>();
|
||||||
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
|
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
|
||||||
public DbSet<Check> Checks => Set<Check>();
|
public DbSet<Check> Checks => Set<Check>();
|
||||||
public DbSet<CheckLine> CheckLines => Set<CheckLine>();
|
public DbSet<CheckLine> CheckLines => Set<CheckLine>();
|
||||||
public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>();
|
public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>();
|
||||||
|
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
|
||||||
|
|
||||||
|
public DbSet<MemberChannelBinding> MemberChannelBindings => Set<MemberChannelBinding>();
|
||||||
|
public DbSet<LineBindingCode> LineBindingCodes => Set<LineBindingCode>();
|
||||||
|
public DbSet<MessagingGroup> MessagingGroups => Set<MessagingGroup>();
|
||||||
|
public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();
|
||||||
|
|
||||||
|
public DbSet<SiteSetting> SiteSettings => Set<SiteSetting>();
|
||||||
|
public DbSet<NotificationSetting> NotificationSettings => Set<NotificationSetting>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
@@ -45,6 +63,23 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Ignore(e => e.IsActive);
|
entity.Ignore(e => e.IsActive);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── UserInvitation (single-use, expiring first-login links) ─────────
|
||||||
|
builder.Entity<UserInvitation>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
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.CreatedBy).HasMaxLength(450).IsRequired();
|
||||||
|
entity.HasIndex(e => e.UserId);
|
||||||
|
entity.HasOne(e => e.User).WithMany()
|
||||||
|
.HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.Ignore(e => e.IsExpired);
|
||||||
|
entity.Ignore(e => e.IsUsed);
|
||||||
|
entity.Ignore(e => e.IsRevoked);
|
||||||
|
entity.Ignore(e => e.IsActive);
|
||||||
|
});
|
||||||
|
|
||||||
// ── AppUser (unchanged + new unique index on MemberId) ──────────────
|
// ── AppUser (unchanged + new unique index on MemberId) ──────────────
|
||||||
builder.Entity<AppUser>(entity =>
|
builder.Entity<AppUser>(entity =>
|
||||||
{
|
{
|
||||||
@@ -60,6 +95,18 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Property(e => e.Description).HasMaxLength(500);
|
entity.Property(e => e.Description).HasMaxLength(500);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── RolePermission (configurable RBAC matrix) ───────────────────────
|
||||||
|
builder.Entity<RolePermission>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.Property(e => e.RoleId).HasMaxLength(450).IsRequired();
|
||||||
|
entity.Property(e => e.Module).HasMaxLength(60).IsRequired();
|
||||||
|
// One row per (role, module).
|
||||||
|
entity.HasIndex(e => new { e.RoleId, e.Module }).IsUnique();
|
||||||
|
entity.HasOne(e => e.Role).WithMany()
|
||||||
|
.HasForeignKey(e => e.RoleId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
// ── FamilyUnit ──────────────────────────────────────────────────────
|
// ── FamilyUnit ──────────────────────────────────────────────────────
|
||||||
builder.Entity<FamilyUnit>(entity =>
|
builder.Entity<FamilyUnit>(entity =>
|
||||||
{
|
{
|
||||||
@@ -77,6 +124,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Property(e => e.NickName).HasMaxLength(100);
|
entity.Property(e => e.NickName).HasMaxLength(100);
|
||||||
entity.Property(e => e.FirstName_zh).HasMaxLength(100);
|
entity.Property(e => e.FirstName_zh).HasMaxLength(100);
|
||||||
entity.Property(e => e.LastName_zh).HasMaxLength(100);
|
entity.Property(e => e.LastName_zh).HasMaxLength(100);
|
||||||
|
entity.Property(e => e.Entity).HasMaxLength(200);
|
||||||
entity.Property(e => e.Gender).HasMaxLength(10);
|
entity.Property(e => e.Gender).HasMaxLength(10);
|
||||||
entity.Property(e => e.BaptismChurch).HasMaxLength(200);
|
entity.Property(e => e.BaptismChurch).HasMaxLength(200);
|
||||||
entity.Property(e => e.Email).HasMaxLength(200);
|
entity.Property(e => e.Email).HasMaxLength(200);
|
||||||
@@ -158,6 +206,57 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
{
|
{
|
||||||
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
|
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
|
||||||
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.DefaultFunctionalClass).HasMaxLength(20).HasDefaultValue("Program");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Form990ExpenseLine (Part IX natural-expense line catalog) ─────────
|
||||||
|
builder.Entity<Form990ExpenseLine>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.LineCode).HasMaxLength(10).IsRequired();
|
||||||
|
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.HasIndex(e => e.LineCode).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Form1099Box (1099 reporting box catalog) ──────────────────────────
|
||||||
|
builder.Entity<Form1099Box>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.BoxCode).HasMaxLength(10).IsRequired();
|
||||||
|
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
|
||||||
|
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.FormType).HasMaxLength(20).IsRequired();
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
entity.HasIndex(e => e.BoxCode).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Payee1099 (1099 recipient master) ────────────────────────────────
|
||||||
|
builder.Entity<Payee1099>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasQueryFilter(p => !p.IsDeleted);
|
||||||
|
entity.Property(e => e.LegalName).HasMaxLength(200).IsRequired();
|
||||||
|
entity.Property(e => e.DisplayName).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.TaxClassification).HasMaxLength(40).IsRequired();
|
||||||
|
entity.Property(e => e.TinType).HasMaxLength(10);
|
||||||
|
entity.Property(e => e.TinLast4).HasMaxLength(4);
|
||||||
|
entity.Property(e => e.State).HasMaxLength(2);
|
||||||
|
entity.Property(e => e.Zip).HasMaxLength(10);
|
||||||
|
entity.Property(e => e.W9Status).HasMaxLength(20).HasDefaultValue(Form1099.W9Status.Missing);
|
||||||
|
entity.Property(e => e.AddressLine1).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.AddressLine2).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.City).HasMaxLength(100);
|
||||||
|
entity.Property(e => e.Email).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.Phone).HasMaxLength(30);
|
||||||
|
entity.Property(e => e.Notes).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.W9BlobPath).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.TinEncrypted).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.DeletedBy).HasMaxLength(450);
|
||||||
|
entity.HasOne(e => e.Member).WithMany()
|
||||||
|
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── ExpenseCategoryGroup ─────────────────────────────────────────────
|
// ── ExpenseCategoryGroup ─────────────────────────────────────────────
|
||||||
@@ -167,6 +266,10 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
||||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
entity.HasOne(e => e.Form990Line).WithMany()
|
||||||
|
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
entity.HasOne(e => e.Form1099Box).WithMany()
|
||||||
|
.HasForeignKey(e => e.Form1099BoxId).OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── ExpenseSubCategory ───────────────────────────────────────────────
|
// ── ExpenseSubCategory ───────────────────────────────────────────────
|
||||||
@@ -178,6 +281,10 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
entity.HasOne(e => e.Group).WithMany(g => g.SubCategories)
|
entity.HasOne(e => e.Group).WithMany(g => g.SubCategories)
|
||||||
.HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(e => e.Form990Line).WithMany()
|
||||||
|
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
entity.HasOne(e => e.Form1099Box).WithMany()
|
||||||
|
.HasForeignKey(e => e.Form1099BoxId).OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Expense ──────────────────────────────────────────────────────────
|
// ── Expense ──────────────────────────────────────────────────────────
|
||||||
@@ -206,12 +313,73 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
|
|
||||||
entity.HasOne(e => e.Ministry).WithMany()
|
entity.HasOne(e => e.Ministry).WithMany()
|
||||||
.HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(e => e.Member).WithMany()
|
||||||
|
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
entity.HasOne(e => e.Payee).WithMany()
|
||||||
|
.HasForeignKey(e => e.PayeeId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ExpenseLine (category breakdown of one Expense) ──────────────────
|
||||||
|
builder.Entity<ExpenseLine>(entity =>
|
||||||
|
{
|
||||||
|
// Mirror the parent Expense's soft-delete filter (required relationship).
|
||||||
|
entity.HasQueryFilter(l => !l.Expense!.IsDeleted);
|
||||||
|
|
||||||
|
entity.Property(e => e.FunctionalClass).HasMaxLength(20);
|
||||||
|
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
|
||||||
|
entity.Property(e => e.Description).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
|
||||||
|
entity.HasIndex(e => e.ExpenseId);
|
||||||
|
|
||||||
|
entity.HasOne(e => e.Expense).WithMany(x => x.Lines)
|
||||||
|
.HasForeignKey(e => e.ExpenseId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ExpenseSnapshot (reusable vendor-payment template) ───────────────
|
||||||
|
builder.Entity<ExpenseSnapshot>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasQueryFilter(s => !s.IsDeleted);
|
||||||
|
|
||||||
|
entity.Property(e => e.Name).HasMaxLength(150).IsRequired();
|
||||||
|
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.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.DeletedBy).HasMaxLength(450);
|
||||||
|
|
||||||
|
entity.HasIndex(e => e.CreatedAt);
|
||||||
|
|
||||||
|
entity.HasOne(e => e.Ministry).WithMany()
|
||||||
|
.HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ExpenseSnapshotLine (category breakdown of one snapshot) ─────────
|
||||||
|
builder.Entity<ExpenseSnapshotLine>(entity =>
|
||||||
|
{
|
||||||
|
// Mirror the parent snapshot's soft-delete filter (required relationship).
|
||||||
|
entity.HasQueryFilter(l => !l.Snapshot!.IsDeleted);
|
||||||
|
|
||||||
|
entity.Property(e => e.FunctionalClass).HasMaxLength(20);
|
||||||
|
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
|
||||||
|
entity.Property(e => e.Description).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
|
||||||
|
entity.HasIndex(e => e.SnapshotId);
|
||||||
|
|
||||||
|
entity.HasOne(e => e.Snapshot).WithMany(x => x.Lines)
|
||||||
|
.HasForeignKey(e => e.SnapshotId).OnDelete(DeleteBehavior.Cascade);
|
||||||
entity.HasOne(e => e.CategoryGroup).WithMany()
|
entity.HasOne(e => e.CategoryGroup).WithMany()
|
||||||
.HasForeignKey(e => e.CategoryGroupId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(e => e.CategoryGroupId).OnDelete(DeleteBehavior.Restrict);
|
||||||
entity.HasOne(e => e.SubCategory).WithMany()
|
entity.HasOne(e => e.SubCategory).WithMany()
|
||||||
.HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict);
|
||||||
entity.HasOne(e => e.Member).WithMany()
|
|
||||||
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── ChurchProfile (singleton settings) ───────────────────────────────
|
// ── ChurchProfile (singleton settings) ───────────────────────────────
|
||||||
@@ -225,12 +393,49 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Property(e => e.BankName).HasMaxLength(200);
|
entity.Property(e => e.BankName).HasMaxLength(200);
|
||||||
entity.Property(e => e.BankAccountNumber).HasMaxLength(50);
|
entity.Property(e => e.BankAccountNumber).HasMaxLength(50);
|
||||||
entity.Property(e => e.BankRoutingNumber).HasMaxLength(50);
|
entity.Property(e => e.BankRoutingNumber).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.PayerEin).HasMaxLength(20);
|
||||||
|
entity.Property(e => e.NameZh).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.Phone).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.Email).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.Website).HasMaxLength(300);
|
||||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.AiProvider).HasMaxLength(20).HasDefaultValue("Claude");
|
||||||
|
entity.Property(e => e.ClaudeModel).HasMaxLength(100).HasDefaultValue("claude-haiku-4-5-20251001");
|
||||||
|
entity.Property(e => e.ClaudeApiKey).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.GeminiModel).HasMaxLength(100).HasDefaultValue("gemini-2.5-flash-lite");
|
||||||
|
entity.Property(e => e.GeminiApiKey).HasMaxLength(500);
|
||||||
// Optimistic-concurrency token for safe check-number allocation.
|
// Optimistic-concurrency token for safe check-number allocation.
|
||||||
entity.Property(e => e.xmin).IsRowVersion();
|
entity.Property(e => e.xmin).IsRowVersion();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── SiteSetting (singleton presentation/locale settings) ─────────────
|
||||||
|
builder.Entity<SiteSetting>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.SiteTitle).HasMaxLength(200).IsRequired();
|
||||||
|
entity.Property(e => e.SiteTitleZh).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.DefaultLanguage).HasMaxLength(10).IsRequired();
|
||||||
|
entity.Property(e => e.TimeZone).HasMaxLength(100).IsRequired();
|
||||||
|
entity.Property(e => e.DateFormat).HasMaxLength(50).IsRequired();
|
||||||
|
entity.Property(e => e.Currency).HasMaxLength(10).IsRequired();
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── NotificationSetting (singleton SMTP + Line settings) ─────────────
|
||||||
|
builder.Entity<NotificationSetting>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.SmtpHost).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.SmtpUser).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.SmtpPassword).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.FromAddress).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.FromName).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.LineChannelAccessToken).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.LineChannelSecret).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Check (disbursement) ─────────────────────────────────────────────
|
// ── Check (disbursement) ─────────────────────────────────────────────
|
||||||
builder.Entity<Check>(entity =>
|
builder.Entity<Check>(entity =>
|
||||||
{
|
{
|
||||||
@@ -311,5 +516,55 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
entity.HasIndex(e => new { e.Year, e.Month }).IsUnique();
|
entity.HasIndex(e => new { e.Year, e.Month }).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Notifications (email + Line) ─────────────────────────────────────
|
||||||
|
builder.Entity<MemberChannelBinding>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
|
||||||
|
entity.Property(e => e.ExternalId).HasMaxLength(100).IsRequired();
|
||||||
|
entity.HasIndex(e => new { e.MemberId, e.Channel }).IsUnique();
|
||||||
|
entity.HasIndex(e => new { e.Channel, e.ExternalId }).IsUnique();
|
||||||
|
entity.HasOne(e => e.Member).WithMany()
|
||||||
|
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<LineBindingCode>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.Code).HasMaxLength(20).IsRequired();
|
||||||
|
entity.HasIndex(e => e.Code);
|
||||||
|
entity.HasOne(e => e.Member).WithMany()
|
||||||
|
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<MessagingGroup>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
|
||||||
|
entity.Property(e => e.ExternalId).HasMaxLength(100).IsRequired();
|
||||||
|
entity.Property(e => e.Name).HasMaxLength(200);
|
||||||
|
entity.HasIndex(e => new { e.Channel, e.ExternalId }).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<NotificationLog>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
|
||||||
|
entity.Property(e => e.TargetType).HasMaxLength(20).IsRequired();
|
||||||
|
entity.Property(e => e.TargetExternalId).HasMaxLength(200).IsRequired();
|
||||||
|
entity.Property(e => e.Subject).HasMaxLength(300);
|
||||||
|
entity.Property(e => e.Status).HasMaxLength(20).IsRequired();
|
||||||
|
entity.Property(e => e.SentByUserId).HasMaxLength(450).IsRequired();
|
||||||
|
entity.HasIndex(e => e.SentAt);
|
||||||
|
entity.HasIndex(e => e.Channel);
|
||||||
|
entity.HasOne(e => e.Member).WithMany()
|
||||||
|
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
entity.HasOne(e => e.MessagingGroup).WithMany()
|
||||||
|
.HasForeignKey(e => e.MessagingGroupId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── SystemLog / AuditLog (append-only) ───────────────────────────────
|
||||||
|
// Mapped here for SCHEMA only — there are deliberately no DbSets on this
|
||||||
|
// context, so business code can't write logs through the audited context.
|
||||||
|
// Runtime reads/writes go through the dedicated LogDbContext. Including
|
||||||
|
// them in the model lets the single startup migration create the tables.
|
||||||
|
LogModelConfiguration.Configure(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
|
|
||||||
namespace ROLAC.API.Data;
|
namespace ROLAC.API.Data;
|
||||||
@@ -27,6 +28,8 @@ public static class DbSeeder
|
|||||||
("Hospitality", "招待", 8),
|
("Hospitality", "招待", 8),
|
||||||
("Children", "兒牧", 9),
|
("Children", "兒牧", 9),
|
||||||
("Catering", "餐飲", 10),
|
("Catering", "餐飲", 10),
|
||||||
|
("Cell Groups", "小組牧養", 11),
|
||||||
|
("Special Events", "特別活動", 12),
|
||||||
];
|
];
|
||||||
|
|
||||||
// (GroupEn, GroupZh, Sort, SubItems[(SubEn, SubZh)])
|
// (GroupEn, GroupZh, Sort, SubItems[(SubEn, SubZh)])
|
||||||
@@ -34,15 +37,132 @@ public static class DbSeeder
|
|||||||
[
|
[
|
||||||
("Equipment", "設備", 1, [("Purchase","購置"),("Rental","租借"),("Maintenance & Repair","維修")]),
|
("Equipment", "設備", 1, [("Purchase","購置"),("Rental","租借"),("Maintenance & Repair","維修")]),
|
||||||
("Consumables", "消耗品", 2, [("Batteries","電池"),("Accessories","配件"),("Cleaning Supplies","清潔用品"),("Office Supplies","文具")]),
|
("Consumables", "消耗品", 2, [("Batteries","電池"),("Accessories","配件"),("Cleaning Supplies","清潔用品"),("Office Supplies","文具")]),
|
||||||
("Food & Beverage", "餐飲", 3, [("Catering","出餐費用"),("Food Ingredients","食材採購"),("Utensils","器具"),("Consumables","消耗品")]),
|
("Food & Beverage", "餐飲", 3, [("Catering","出餐費用"),("Food Ingredients","食材採購"),("Utensils","器具"),("Disposable Tableware","一次性餐具")]),
|
||||||
("Training", "培訓", 4, [("Course Fees","課程費用"),("Books","書籍"),("Conference","研討會"),("Travel","差旅")]),
|
("Training", "培訓", 4, [("Course Fees","課程費用"),("Books","書籍"),("Conference","研討會"),("Travel","差旅")]),
|
||||||
("Materials", "教材", 5, [("Printing","印刷費用"),("Craft Supplies","手工材料"),("Copyright & Licensing","版權購買")]),
|
("Materials", "教材", 5, [("Curriculum Printing","教材印刷"),("Craft Supplies","手工材料"),("Copyright & Licensing","版權購買")]),
|
||||||
("Facility", "場地", 6, [("Rent","場地租金"),("Utilities","水電"),("Property Insurance","財產保險"),("Decoration","裝飾")]),
|
("Facility", "場地", 6, [("Rent","場地租金"),("Utilities","水電"),("Property Insurance","財產保險"),("Decoration","裝飾"),("Repairs & Maintenance","修繕維護")]),
|
||||||
("Printing", "印刷", 7, [("Bulletins","週報"),("Order of Service","程序單"),("Posters","海報")]),
|
("Printing", "印刷", 7, [("Bulletins","週報"),("Order of Service","程序單"),("Posters","海報"),("Advertising & Promotion","廣告推廣")]),
|
||||||
("Missions", "宣教", 8, [("Offering Transfer","奉獻轉帳"),("Missionary Support","宣教士支援"),("Travel","差旅")]),
|
("Missions", "宣教", 8, [("Offering Transfer","奉獻轉帳"),("Missionary Support","宣教士支援"),("Foreign Missions Support","國外宣教支援"),("Travel","差旅")]),
|
||||||
("Benevolence", "關懷救助", 9, [("Emergency Aid","急難救助"),("Condolence Gifts","慰問禮品"),("Visit Expenses","探訪費用")]),
|
("Benevolence", "關懷救助", 9, [("Emergency Aid","急難救助"),("Condolence Gifts","慰問禮品"),("Visit Expenses","探訪費用")]),
|
||||||
("Other", "其他", 10, [("Miscellaneous","雜支")]),
|
("Other", "其他", 10, [("Miscellaneous","雜支"),("Gifts","禮品")]),
|
||||||
("Personnel", "人事", 11, [("Salary & Wages","薪資"),("Payroll Taxes","薪資稅費"),("Employee Benefits","員工福利"),("Workers Compensation","勞工保險"),("Honorarium","酬庸"),("Staff Training","同工進修"),("Contract Labor","外包勞務")]),
|
("Personnel", "人事", 11, [("Officer / Key Employee Compensation","主要職員薪酬"),("Salary & Wages","薪資"),("Payroll Taxes","薪資稅費"),("Employee Benefits","員工福利"),("Retirement / Pension","退休金"),("Workers Compensation","勞工保險"),("Honorarium","酬庸"),("Staff Training","同工進修"),("Contract Labor","外包勞務")]),
|
||||||
|
("Professional Services", "專業服務", 12, [("Legal","法律服務"),("Accounting & Audit","會計與審計"),("Other Professional","其他專業服務")]),
|
||||||
|
("Information Technology", "資訊科技", 13, [("Software & Subscriptions","軟體與訂閱"),("Website & Hosting","網站與主機"),("Internet & Telecom","網路與電信")]),
|
||||||
|
("Finance & Banking", "財務與銀行", 14, [("Interest","利息支出"),("Bank & Processing Fees","銀行/金流手續費")]),
|
||||||
|
];
|
||||||
|
|
||||||
|
// (LineCode, Name_en, Name_zh, Sort)
|
||||||
|
private static readonly (string Code, string En, string Zh, int Sort)[] Form990LineSeed =
|
||||||
|
[
|
||||||
|
("1", "Grants to domestic organizations", "對國內機構之捐贈", 1),
|
||||||
|
("2", "Grants to domestic individuals", "對國內個人之捐贈", 2),
|
||||||
|
("3", "Grants to foreign organizations/individuals", "對國外之捐贈", 3),
|
||||||
|
("5", "Compensation of current officers / key employees", "主要職員/負責人薪酬", 4),
|
||||||
|
("7", "Other salaries and wages", "薪資", 5),
|
||||||
|
("8", "Pension plan accruals and contributions", "退休金提撥", 6),
|
||||||
|
("9", "Other employee benefits", "員工福利", 7),
|
||||||
|
("10", "Payroll taxes", "薪資稅", 8),
|
||||||
|
("11b", "Legal fees", "法律服務費", 9),
|
||||||
|
("11c", "Accounting fees", "會計與審計費", 10),
|
||||||
|
("11g", "Other fees for services (non-employee)", "其他勞務報酬(非員工)", 11),
|
||||||
|
("12", "Advertising and promotion", "廣告與推廣", 12),
|
||||||
|
("13", "Office expenses", "辦公費用", 13),
|
||||||
|
("14", "Information technology", "資訊科技", 14),
|
||||||
|
("16", "Occupancy", "場地佔用", 15),
|
||||||
|
("17", "Travel", "差旅", 16),
|
||||||
|
("19", "Conferences, conventions, and meetings", "會議與研習", 17),
|
||||||
|
("20", "Interest", "利息", 18),
|
||||||
|
("22", "Depreciation", "折舊", 19),
|
||||||
|
("23", "Insurance", "保險", 20),
|
||||||
|
("24", "Other expenses", "其他費用", 21),
|
||||||
|
];
|
||||||
|
|
||||||
|
// (GroupEn, SubEn, LineCode) — default natural-category → 990 line mapping.
|
||||||
|
private static readonly (string GroupEn, string SubEn, string Code)[] Form990SubMappingSeed =
|
||||||
|
[
|
||||||
|
("Personnel", "Officer / Key Employee Compensation", "5"),
|
||||||
|
("Personnel", "Salary & Wages", "7"),
|
||||||
|
("Personnel", "Payroll Taxes", "10"),
|
||||||
|
("Personnel", "Employee Benefits", "9"),
|
||||||
|
("Personnel", "Retirement / Pension","8"),
|
||||||
|
("Personnel", "Workers Compensation","9"),
|
||||||
|
("Personnel", "Honorarium", "11g"),
|
||||||
|
("Personnel", "Contract Labor", "11g"),
|
||||||
|
("Personnel", "Staff Training", "19"),
|
||||||
|
("Facility", "Rent", "16"),
|
||||||
|
("Facility", "Utilities", "16"),
|
||||||
|
("Facility", "Property Insurance", "23"),
|
||||||
|
("Facility", "Decoration", "24"),
|
||||||
|
// Building repairs & maintenance (plumbing, electrical, painting) are part of Occupancy.
|
||||||
|
("Facility", "Repairs & Maintenance", "16"),
|
||||||
|
("Training", "Course Fees", "19"),
|
||||||
|
("Training", "Conference", "19"),
|
||||||
|
("Training", "Books", "24"),
|
||||||
|
("Training", "Travel", "17"),
|
||||||
|
("Missions", "Travel", "17"),
|
||||||
|
// Domestic missions support is paid to individual missionaries/families → line 2 (grants to individuals).
|
||||||
|
("Missions", "Offering Transfer", "2"),
|
||||||
|
("Missions", "Missionary Support", "2"),
|
||||||
|
("Missions", "Foreign Missions Support", "3"),
|
||||||
|
("Benevolence", "Emergency Aid", "2"),
|
||||||
|
("Benevolence", "Condolence Gifts", "2"),
|
||||||
|
// Visitation is the church's own travel/program cost, not a grant to an individual.
|
||||||
|
("Benevolence", "Visit Expenses", "17"),
|
||||||
|
("Consumables", "Office Supplies", "13"),
|
||||||
|
// General supplies belong with office expenses (line 13), not the "Other" catch-all.
|
||||||
|
("Consumables", "Batteries", "13"),
|
||||||
|
("Consumables", "Accessories", "13"),
|
||||||
|
("Consumables", "Cleaning Supplies", "13"),
|
||||||
|
// IRS line 13 covers equipment rental and maintenance.
|
||||||
|
("Equipment", "Rental", "13"),
|
||||||
|
("Equipment", "Maintenance & Repair", "13"),
|
||||||
|
("Printing", "Bulletins", "13"),
|
||||||
|
("Printing", "Order of Service", "13"),
|
||||||
|
("Printing", "Posters", "12"),
|
||||||
|
("Printing", "Advertising & Promotion", "12"),
|
||||||
|
("Materials", "Curriculum Printing", "13"),
|
||||||
|
// Classroom/craft supplies fall under IRS line 13 office expenses ("supplies… classroom…").
|
||||||
|
("Materials", "Craft Supplies", "13"),
|
||||||
|
("Professional Services", "Legal", "11b"),
|
||||||
|
("Professional Services", "Accounting & Audit", "11c"),
|
||||||
|
("Professional Services", "Other Professional", "11g"),
|
||||||
|
("Information Technology", "Software & Subscriptions", "14"),
|
||||||
|
("Information Technology", "Website & Hosting", "14"),
|
||||||
|
("Information Technology", "Internet & Telecom", "14"),
|
||||||
|
("Finance & Banking", "Interest", "20"),
|
||||||
|
// Bank/processing fees are office expenses per IRS line 13 (consistent with Interest → 20).
|
||||||
|
("Finance & Banking", "Bank & Processing Fees", "13"),
|
||||||
|
// Appreciation/outreach gifts have no natural 990 line; mapped to 24 explicitly so this
|
||||||
|
// deliberate "Other" choice doesn't inflate UnmappedExpenseCount. (Benevolence gifts → line 2.)
|
||||||
|
("Other", "Gifts", "24"),
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly (string Code, string En, string Zh, string FormType, int Sort)[] Form1099BoxSeed =
|
||||||
|
[
|
||||||
|
(Form1099.BoxNec1, "Nonemployee compensation", "非員工報酬", "1099-NEC", 1),
|
||||||
|
(Form1099.BoxMisc1, "Rents", "租金", "1099-MISC", 2),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Only service/rent subcategories get a box. Everything else stays unmapped (not reportable).
|
||||||
|
private static readonly (string GroupEn, string SubEn, string Code)[] Form1099SubMappingSeed =
|
||||||
|
[
|
||||||
|
("Personnel", "Honorarium", Form1099.BoxNec1),
|
||||||
|
("Personnel", "Contract Labor", Form1099.BoxNec1),
|
||||||
|
("Professional Services", "Legal", Form1099.BoxNec1),
|
||||||
|
("Professional Services", "Accounting & Audit", Form1099.BoxNec1),
|
||||||
|
("Professional Services", "Other Professional", Form1099.BoxNec1),
|
||||||
|
("Facility", "Rent", Form1099.BoxMisc1),
|
||||||
|
];
|
||||||
|
|
||||||
|
// One-time corrections for subcategories that were mapped to the WRONG line in an earlier
|
||||||
|
// seed. The normal mapping loop below only fills NULLs, so it cannot fix an existing bad
|
||||||
|
// value — this block does. Idempotent: each row fires only while the subcategory still holds
|
||||||
|
// the OLD line, so it never clobbers a deliberate admin re-mapping. (GroupEn, SubEn, Old, New)
|
||||||
|
private static readonly (string GroupEn, string SubEn, string OldCode, string NewCode)[] Form990RemapSeed =
|
||||||
|
[
|
||||||
|
("Benevolence", "Visit Expenses", "2", "17"),
|
||||||
|
("Missions", "Missionary Support", "1", "2"),
|
||||||
|
("Missions", "Offering Transfer", "1", "2"),
|
||||||
];
|
];
|
||||||
|
|
||||||
private static readonly (string Name, string Description)[] Roles =
|
private static readonly (string Name, string Description)[] Roles =
|
||||||
@@ -62,6 +182,91 @@ public static class DbSeeder
|
|||||||
("visitor", "Visitor — public pages only"),
|
("visitor", "Visitor — public pages only"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Default permission matrix — mirrors the hard-coded [Authorize(Roles=...)] rules that
|
||||||
|
// existed before the configurable RBAC system, so day-one behavior is unchanged.
|
||||||
|
// super_admin is intentionally absent: it bypasses all checks (see PermissionAuthorizationHandler).
|
||||||
|
// R=Read, W=Write, D=Delete, A=Approve. Rows are inserted only if missing, so an admin's
|
||||||
|
// later edits via the Permissions UI are never clobbered on restart.
|
||||||
|
private static readonly (string Role, string Module, bool R, bool W, bool D, bool A)[] RolePermissionSeed =
|
||||||
|
[
|
||||||
|
// Secretary — manages member data.
|
||||||
|
("secretary", Modules.Members, true, true, true, false),
|
||||||
|
|
||||||
|
// Pastor — read-only overview of members and all expenses.
|
||||||
|
("pastor", Modules.Members, true, false, false, false),
|
||||||
|
("pastor", Modules.Expenses, true, false, false, false),
|
||||||
|
|
||||||
|
// Finance — full control over the finance modules.
|
||||||
|
("finance", Modules.Givings, true, true, true, false),
|
||||||
|
("finance", Modules.GivingCategories, true, true, true, false),
|
||||||
|
("finance", Modules.Expenses, true, true, true, true),
|
||||||
|
("finance", Modules.ExpenseCategories, true, true, true, false),
|
||||||
|
("finance", Modules.OfferingSessions, true, true, true, true),
|
||||||
|
("finance", Modules.FinanceDashboard, true, false, false, false),
|
||||||
|
("finance", Modules.MonthlyStatements, true, true, false, true),
|
||||||
|
("finance", Modules.ChurchProfile, true, true, false, false),
|
||||||
|
("finance", Modules.Disbursements, true, true, true, true),
|
||||||
|
("finance", Modules.Form990Report, true, false, false, false),
|
||||||
|
// Form1099 — finance manages recipients and tracks filings; pastor and board_member
|
||||||
|
// get read-only oversight (same pattern as Form990Report). No Approve semantics.
|
||||||
|
("finance", Modules.Form1099, true, true, true, false),
|
||||||
|
("pastor", Modules.Form1099, true, false, false, false),
|
||||||
|
("board_member", Modules.Form1099, true, false, false, false),
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
("pastor", Modules.Form990Report, true, false, false, false),
|
||||||
|
("board_member", Modules.Form990Report, true, false, false, false),
|
||||||
|
|
||||||
|
// Ministries — secretary maintains the list; coworker_chair edits; ministry
|
||||||
|
// leaders and pastor read.
|
||||||
|
("secretary", Modules.Ministries, true, true, true, false),
|
||||||
|
("coworker_chair", Modules.Ministries, true, true, false, false),
|
||||||
|
("ministry_leader", Modules.Ministries, true, false, false, false),
|
||||||
|
("pastor", Modules.Ministries, true, false, false, false),
|
||||||
|
|
||||||
|
// Meal attendance — secretary and coworkers record; finance and pastor read.
|
||||||
|
("secretary", Modules.MealAttendance, true, true, false, false),
|
||||||
|
("coworker", Modules.MealAttendance, true, true, false, false),
|
||||||
|
("finance", Modules.MealAttendance, true, false, false, false),
|
||||||
|
("pastor", Modules.MealAttendance, true, false, false, false),
|
||||||
|
|
||||||
|
// Users, Permissions, and Settings are intentionally super_admin-only:
|
||||||
|
// super_admin bypasses all checks, so no seed rows are needed here.
|
||||||
|
];
|
||||||
|
|
||||||
|
public static async Task SeedRolePermissionsAsync(AppDbContext db)
|
||||||
|
{
|
||||||
|
var rolesByName = await db.Roles
|
||||||
|
.Where(r => r.Name != null)
|
||||||
|
.ToDictionaryAsync(r => r.Name!, r => r.Id);
|
||||||
|
|
||||||
|
foreach (var (role, module, read, write, delete, approve) in RolePermissionSeed)
|
||||||
|
{
|
||||||
|
if (!rolesByName.TryGetValue(role, out var roleId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var exists = await db.RolePermissions.AnyAsync(p => p.RoleId == roleId && p.Module == module);
|
||||||
|
if (exists)
|
||||||
|
continue; // never clobber an admin's edit
|
||||||
|
|
||||||
|
db.RolePermissions.Add(new RolePermission
|
||||||
|
{
|
||||||
|
RoleId = roleId,
|
||||||
|
Module = module,
|
||||||
|
CanRead = read,
|
||||||
|
CanWrite = write,
|
||||||
|
CanDelete = delete,
|
||||||
|
CanApprove = approve,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public static async Task SeedRolesAsync(RoleManager<AppRole> roleManager)
|
public static async Task SeedRolesAsync(RoleManager<AppRole> roleManager)
|
||||||
{
|
{
|
||||||
foreach (var (name, description) in Roles)
|
foreach (var (name, description) in Roles)
|
||||||
@@ -101,13 +306,35 @@ public static class DbSeeder
|
|||||||
foreach (var (en, zh, sort) in MinistrySeed)
|
foreach (var (en, zh, sort) in MinistrySeed)
|
||||||
{
|
{
|
||||||
if (!await db.Ministries.AnyAsync(m => m.Name_en == en))
|
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 });
|
db.Ministries.Add(new Ministry
|
||||||
|
{
|
||||||
|
Name_en = en, Name_zh = zh, SortOrder = sort, IsActive = true,
|
||||||
|
DefaultFunctionalClass = en == "Administration"
|
||||||
|
? FunctionalClasses.ManagementGeneral
|
||||||
|
: FunctionalClasses.Program,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task SeedExpenseCategoriesAsync(AppDbContext db)
|
public static async Task SeedExpenseCategoriesAsync(AppDbContext db)
|
||||||
{
|
{
|
||||||
|
// One-time renames to remove same-name-different-parent ambiguity. Idempotent:
|
||||||
|
// only fires while the old name still exists. (New installs never hit this.)
|
||||||
|
var renames = new (string GroupEn, string OldSub, string NewEn, string NewZh)[]
|
||||||
|
{
|
||||||
|
("Food & Beverage", "Consumables", "Disposable Tableware", "一次性餐具"),
|
||||||
|
("Materials", "Printing", "Curriculum Printing", "教材印刷"),
|
||||||
|
};
|
||||||
|
foreach (var (groupEn, oldSub, newEn, newZh) in renames)
|
||||||
|
{
|
||||||
|
var grp = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == groupEn);
|
||||||
|
if (grp is null) continue;
|
||||||
|
var sub = await db.ExpenseSubCategories.FirstOrDefaultAsync(s => s.GroupId == grp.Id && s.Name_en == oldSub);
|
||||||
|
if (sub is not null) { sub.Name_en = newEn; sub.Name_zh = newZh; }
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
foreach (var (gEn, gZh, gSort, subs) in ExpenseCategorySeed)
|
foreach (var (gEn, gZh, gSort, subs) in ExpenseCategorySeed)
|
||||||
{
|
{
|
||||||
var group = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == gEn);
|
var group = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == gEn);
|
||||||
@@ -130,6 +357,65 @@ public static class DbSeeder
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async Task SeedForm990ExpenseLinesAsync(AppDbContext db)
|
||||||
|
{
|
||||||
|
foreach (var (code, en, zh, sort) in Form990LineSeed)
|
||||||
|
{
|
||||||
|
if (!await db.Form990ExpenseLines.AnyAsync(l => l.LineCode == code))
|
||||||
|
db.Form990ExpenseLines.Add(new Form990ExpenseLine
|
||||||
|
{ LineCode = code, Name_en = en, Name_zh = zh, SortOrder = sort, IsActive = true });
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var linesByCode = await db.Form990ExpenseLines.ToDictionaryAsync(l => l.LineCode, l => l.Id);
|
||||||
|
var fallbackId = linesByCode["24"];
|
||||||
|
|
||||||
|
// Every group defaults to line 24 (safety net); precise mapping lives on subcategories.
|
||||||
|
foreach (var group in await db.ExpenseCategoryGroups.ToListAsync())
|
||||||
|
group.Form990LineId ??= fallbackId;
|
||||||
|
|
||||||
|
// Subcategory default mappings — only set when not already mapped (never clobber an admin edit).
|
||||||
|
var subsByKey = await db.ExpenseSubCategories.Include(s => s.Group).ToListAsync();
|
||||||
|
foreach (var (groupEn, subEn, code) in Form990SubMappingSeed)
|
||||||
|
{
|
||||||
|
var sub = subsByKey.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn);
|
||||||
|
if (sub is not null && sub.Form990LineId is null && linesByCode.TryGetValue(code, out var lineId))
|
||||||
|
sub.Form990LineId = lineId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correct earlier mis-mappings on existing databases (see Form990RemapSeed). Only fires
|
||||||
|
// while the subcategory still holds the OLD line, so a later admin edit is never clobbered.
|
||||||
|
foreach (var (groupEn, subEn, oldCode, newCode) in Form990RemapSeed)
|
||||||
|
{
|
||||||
|
var sub = subsByKey.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn);
|
||||||
|
if (sub is null) continue;
|
||||||
|
if (linesByCode.TryGetValue(oldCode, out var oldId)
|
||||||
|
&& linesByCode.TryGetValue(newCode, out var newId)
|
||||||
|
&& sub.Form990LineId == oldId)
|
||||||
|
sub.Form990LineId = newId;
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task SeedForm1099BoxesAsync(AppDbContext db)
|
||||||
|
{
|
||||||
|
foreach (var (code, en, zh, formType, sort) in Form1099BoxSeed)
|
||||||
|
if (!await db.Form1099Boxes.AnyAsync(b => b.BoxCode == code))
|
||||||
|
db.Form1099Boxes.Add(new Form1099Box
|
||||||
|
{ BoxCode = code, Name_en = en, Name_zh = zh, FormType = formType, SortOrder = sort, IsActive = true });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var boxesByCode = await db.Form1099Boxes.ToDictionaryAsync(b => b.BoxCode, b => b.Id);
|
||||||
|
var subs = await db.ExpenseSubCategories.Include(s => s.Group).ToListAsync();
|
||||||
|
foreach (var (groupEn, subEn, code) in Form1099SubMappingSeed)
|
||||||
|
{
|
||||||
|
var sub = subs.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn);
|
||||||
|
if (sub is not null && sub.Form1099BoxId is null && boxesByCode.TryGetValue(code, out var boxId))
|
||||||
|
sub.Form1099BoxId = boxId;
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public static async Task SeedChurchProfileAsync(AppDbContext db)
|
public static async Task SeedChurchProfileAsync(AppDbContext db)
|
||||||
{
|
{
|
||||||
// Singleton row used by the disbursement module (issuer info + check counter).
|
// Singleton row used by the disbursement module (issuer info + check counter).
|
||||||
@@ -146,6 +432,50 @@ public static class DbSeeder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async Task SeedSiteSettingAsync(AppDbContext db)
|
||||||
|
{
|
||||||
|
// Singleton row holding site-wide presentation/locale settings.
|
||||||
|
if (!await db.SiteSettings.AnyAsync())
|
||||||
|
{
|
||||||
|
db.SiteSettings.Add(new SiteSetting
|
||||||
|
{
|
||||||
|
SiteTitle = "River Of Life Christian Church",
|
||||||
|
SiteTitleZh = "生命河靈糧堂",
|
||||||
|
DefaultLanguage = "en",
|
||||||
|
TimeZone = "America/Los_Angeles",
|
||||||
|
DateFormat = "yyyy-MM-dd",
|
||||||
|
Currency = "USD",
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task SeedNotificationSettingAsync(AppDbContext db, IConfiguration config)
|
||||||
|
{
|
||||||
|
// Singleton row that becomes the runtime source of truth for SMTP + Line. Seed it once
|
||||||
|
// from the legacy "Smtp"/"Line" appsettings sections so existing config carries over.
|
||||||
|
if (!await db.NotificationSettings.AnyAsync())
|
||||||
|
{
|
||||||
|
var smtp = config.GetSection("Smtp");
|
||||||
|
var line = config.GetSection("Line");
|
||||||
|
db.NotificationSettings.Add(new NotificationSetting
|
||||||
|
{
|
||||||
|
EnableEmail = !string.IsNullOrWhiteSpace(smtp["Host"]),
|
||||||
|
SmtpHost = smtp["Host"] ?? "",
|
||||||
|
SmtpPort = int.TryParse(smtp["Port"], out var port) ? port : 587,
|
||||||
|
SmtpUseSsl = !bool.TryParse(smtp["UseSsl"], out var ssl) || ssl,
|
||||||
|
SmtpUser = smtp["User"] ?? "",
|
||||||
|
SmtpPassword = smtp["Password"] ?? "",
|
||||||
|
FromAddress = smtp["FromAddress"] ?? "",
|
||||||
|
FromName = smtp["FromName"] ?? "",
|
||||||
|
EnableLine = !string.IsNullOrWhiteSpace(line["ChannelAccessToken"]),
|
||||||
|
LineChannelAccessToken = line["ChannelAccessToken"] ?? "",
|
||||||
|
LineChannelSecret = line["ChannelSecret"] ?? "",
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds roles and (in Development) the default admin account.
|
/// Seeds roles and (in Development) the default admin account.
|
||||||
/// Called once on application startup after migrations have been applied.
|
/// Called once on application startup after migrations have been applied.
|
||||||
@@ -155,14 +485,20 @@ public static class DbSeeder
|
|||||||
var roleManager = services.GetRequiredService<RoleManager<AppRole>>();
|
var roleManager = services.GetRequiredService<RoleManager<AppRole>>();
|
||||||
var userManager = services.GetRequiredService<UserManager<AppUser>>();
|
var userManager = services.GetRequiredService<UserManager<AppUser>>();
|
||||||
var env = services.GetRequiredService<IWebHostEnvironment>();
|
var env = services.GetRequiredService<IWebHostEnvironment>();
|
||||||
|
var config = services.GetRequiredService<IConfiguration>();
|
||||||
|
|
||||||
await SeedRolesAsync(roleManager);
|
await SeedRolesAsync(roleManager);
|
||||||
|
|
||||||
var db = services.GetRequiredService<AppDbContext>();
|
var db = services.GetRequiredService<AppDbContext>();
|
||||||
|
await SeedRolePermissionsAsync(db);
|
||||||
await SeedGivingCategoriesAsync(db);
|
await SeedGivingCategoriesAsync(db);
|
||||||
await SeedMinistriesAsync(db);
|
await SeedMinistriesAsync(db);
|
||||||
await SeedExpenseCategoriesAsync(db);
|
await SeedExpenseCategoriesAsync(db);
|
||||||
|
await SeedForm990ExpenseLinesAsync(db);
|
||||||
|
await SeedForm1099BoxesAsync(db);
|
||||||
await SeedChurchProfileAsync(db);
|
await SeedChurchProfileAsync(db);
|
||||||
|
await SeedSiteSettingAsync(db);
|
||||||
|
await SeedNotificationSettingAsync(db, config);
|
||||||
|
|
||||||
if (env.IsDevelopment())
|
if (env.IsDevelopment())
|
||||||
await SeedAdminUserAsync(userManager);
|
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);
|
||||||
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
using ROLAC.API.Entities.Base;
|
using ROLAC.API.Entities.Base;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
|
||||||
namespace ROLAC.API.Data.Interceptors;
|
namespace ROLAC.API.Data.Interceptors;
|
||||||
|
|
||||||
public class AuditSaveChangesInterceptor : SaveChangesInterceptor
|
public class AuditSaveChangesInterceptor : SaveChangesInterceptor
|
||||||
{
|
{
|
||||||
private readonly IHttpContextAccessor _http;
|
private readonly CurrentUserAccessor _currentUser;
|
||||||
|
|
||||||
public AuditSaveChangesInterceptor(IHttpContextAccessor http) => _http = http;
|
public AuditSaveChangesInterceptor(CurrentUserAccessor currentUser) => _currentUser = currentUser;
|
||||||
|
|
||||||
public override InterceptionResult<int> SavingChanges(
|
public override InterceptionResult<int> SavingChanges(
|
||||||
DbContextEventData eventData, InterceptionResult<int> result)
|
DbContextEventData eventData, InterceptionResult<int> result)
|
||||||
@@ -30,8 +30,7 @@ public class AuditSaveChangesInterceptor : SaveChangesInterceptor
|
|||||||
{
|
{
|
||||||
if (db is null) return;
|
if (db is null) return;
|
||||||
|
|
||||||
var userId = _http.HttpContext?.User
|
var userId = _currentUser.UserIdOrSystem;
|
||||||
.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
foreach (var entry in db.ChangeTracker.Entries())
|
foreach (var entry in db.ChangeTracker.Entries())
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user