Compare commits

..

173 Commits

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:46:44 -07:00
Chris Chen 7356d0e810 Update attendance-counter-page.component.scss
ci-cd-vm / ci-cd (push) Failing after 1m25s
2026-06-23 17:47:16 -07:00
Chris Chen b1e3e23325 Sunday Worship Count
ci-cd-vm / ci-cd (push) Failing after 1m28s
2026-06-23 17:24:12 -07:00
Chris Chen a298d0ee1c update for signalR
ci-cd-vm / ci-cd (push) Successful in 44s
2026-06-23 17:12:17 -07:00
Chris Chen 249ae1164d Update rolac.conf
ci-cd-vm / ci-cd (push) Successful in 44s
2026-06-23 17:03:20 -07:00
Chris Chen c6e3f1db64 Revert "Update rolac.conf"
ci-cd-vm / ci-cd (push) Successful in 39s
This reverts commit bd722933dc.
2026-06-23 16:51:43 -07:00
Chris Chen bd722933dc Update rolac.conf
ci-cd-vm / ci-cd (push) Successful in 46s
2026-06-23 16:10:14 -07:00
Chris Chen f6277aa339 Update angular.json
ci-cd-vm / ci-cd (push) Successful in 1m45s
2026-06-23 15:16:19 -07:00
Chris Chen 2e226e60f5 update mobile view.
ci-cd-vm / ci-cd (push) Failing after 1m13s
2026-06-23 14:18:55 -07:00
Chris Chen 68649223d9 update mobile view. 2026-06-23 14:15:20 -07:00
Chris Chen 9d7c224ad2 Update user-portal.component.scss 2026-06-23 13:53:57 -07:00
Chris Chen 47aec287aa update mobile view for expense. 2026-06-23 13:49:38 -07:00
Chris Chen 5dfca873dd update for shrink image size.
ci-cd-vm / ci-cd (push) Successful in 1m23s
2026-06-23 13:30:20 -07:00
Chris Chen 62592c29ae Add audit logs.
ci-cd-vm / ci-cd (push) Successful in 4m2s
2026-06-23 12:13:47 -07:00
Chris Chen 870eeec82a Add role control 2026-06-23 07:19:08 -07:00
Chris Chen deff2264a6 Create HealthController.cs
ci-cd-vm / ci-cd (push) Failing after 1m41s
2026-06-22 17:57:20 -07:00
Chris Chen 2b28d2079c update for
ci-cd-vm / ci-cd (push) Failing after 2m44s
2026-06-22 17:52:40 -07:00
Chris Chen c7ac431deb Update Dockerfile
ci-cd-vm / ci-cd (push) Failing after 1m9s
2026-06-22 17:12:13 -07:00
Chris Chen 8a159f1b79 Update rolac.conf
ci-cd-vm / ci-cd (push) Failing after 31s
2026-06-22 17:05:41 -07:00
Chris Chen 70ea56280c update license
ci-cd-vm / ci-cd (push) Failing after 1m4s
2026-06-22 16:53:29 -07:00
Chris Chen bcd6b39356 update docker
ci-cd-vm / ci-cd (push) Failing after 40s
2026-06-22 16:37:53 -07:00
Chris Chen 1fb97cfccc Update angular.json
ci-cd-vm / ci-cd (push) Failing after 58s
2026-06-22 16:27:07 -07:00
Chris Chen 0924b1a980 Update docker
ci-cd-vm / ci-cd (push) Failing after 3m6s
2026-06-22 16:15:28 -07:00
Chris Chen 807e88f929 update package.
ci-cd-vm / ci-cd (push) Failing after 18s
2026-06-22 16:12:13 -07:00
Chris Chen b8a4c9b727 ci: retrigger after hook fix
ci-cd-vm / ci-cd (push) Failing after 25s
2026-06-22 16:09:16 -07:00
Chris Chen d2dc568794 ci: trigger first Actions run 2026-06-22 16:05:11 -07:00
Chris Chen a537974edf Update runner 2026-06-22 15:53:51 -07:00
Chris Chen ef3731ba48 Update runnder
ci-cd-nas / build-push (push) Failing after 11s
ci-cd-nas / deploy (push) Has been skipped
2026-06-20 22:36:07 -07:00
Chris Chen ddced87dc6 Update
ci-cd-nas / build-push (push) Failing after 27s
ci-cd-nas / deploy (push) Has been skipped
2026-06-20 22:26:52 -07:00
Chris Chen 7ab8e9703b WIP 2026-06-20 21:06:24 -07:00
Chris Chen aaaae09bd2 Add Line notifications module design spec
Phase 1: Line Messaging API channel with webhook binding (individual +
group), manual send-now, history, and binding/group admin UI. Scheduled
sends and event triggers deferred to phases 2-3; IMessageChannel seam
left for future PWA/WeChat channels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:49:00 -07:00
Chris Chen 8061a60fe5 add quick add entry. 2026-06-20 20:42:06 -07:00
Chris Chen 87425b3276 add attendance 2026-06-20 19:43:15 -07:00
Chris Chen 2af169fa60 Fix null payee. 2026-06-20 18:05:22 -07:00
Chris Chen 3558c67fd7 WIP 2026-06-20 17:51:33 -07:00
Chris Chen f55807fa7d wip 2026-06-20 15:13:23 -07:00
Chris Chen b6c50a38aa Enhance 2026-05-30 09:57:43 -07:00
Chris Chen caed5091f0 Enhance offering session 2026-05-30 00:15:10 -07:00
Chris Chen 769597d769 refactor finance. 2026-05-29 23:56:29 -07:00
Chris Chen 241870fe48 docs: align plan test command with project (ng test headless)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:29:19 -07:00
Chris Chen e817801e14 docs: record dropdown bilingual display convention
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:27:06 -07:00
Chris Chen aef5454202 feat(i18n): bilingual language dropdown on member form dialog
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:27:03 -07:00
Chris Chen 9a94e3b09e feat(i18n): bilingual ministry/category/status dropdowns on expense pages
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:27:00 -07:00
Chris Chen fba0b63214 feat(i18n): bilingual language + roles selectors in user dialogs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 22:13:41 -07:00
Chris Chen e5296e79dc feat(i18n): bilingual gender/status dropdowns on member form + members page 2026-05-29 22:11:39 -07:00
Chris Chen 61e34d343a feat(i18n): bilingual category + method dropdowns on givings page 2026-05-29 22:09:19 -07:00
Chris Chen 126e640731 feat(i18n): bilingual Type/Method dropdowns + line echo on offering session
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 22:07:57 -07:00
Chris Chen 4e15e9f630 feat(i18n): compute bilingual label on giving/ministry/expense-category lookups 2026-05-29 22:06:09 -07:00
Chris Chen 4bee06addb feat(i18n): add central bilingual option lists for enum dropdowns 2026-05-29 22:04:18 -07:00
Chris Chen a99755a5db feat(i18n): add bilingual() display helper 2026-05-29 22:02:50 -07:00
Chris Chen fef3b76a31 docs: implementation plan for bilingual dropdown options
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 21:56:22 -07:00
Chris Chen e37aade69f docs: spec for bilingual (English/Chinese) dropdown options
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 21:50:31 -07:00
Chris Chen fe50ea3d30 WIP 2026-05-29 20:54:08 -07:00
Chris Chen 95fa37ebdf fix(expense): open category read to all authed users; statement lookups via FirstOrDefaultAsync
Final-review findings:
- ExpenseCategoriesController was finance-only at the class level, but the member
  self-service reimbursement form reads the category list to populate its dropdown,
  so members got 403 and could not submit. Open GET to any authenticated user;
  keep group/subcategory writes finance-only (mirrors MinistriesController).
  Verified live with a member-role account: reads 200, writes 403, self-submit 200.
- MonthlyStatementService Update/Finalize now use FirstOrDefaultAsync for
  convention consistency with the rest of the service layer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 19:14:18 -07:00
Chris Chen e1f99158aa fix(expense): resolve current user id from 'sub' JWT claim
Live verification revealed the JWT carries the user id in the 'sub' claim
(NameClaimType=sub, MapInboundClaims=false), so ClaimTypes.NameIdentifier is
null at runtime. This caused ExpensesController.GetMine/GetById to throw
NullReferenceException (500) on the '!.Value', and made the services fall back
to 'system' — silently defeating the self-ownership guard. Resolve via
NameIdentifier (unit tests) then 'sub' (real tokens). Adds a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 19:08:21 -07:00
Chris Chen 95008788f3 feat(expense): wire routes + sidebar nav for expense pages
Also fix kendo-grid [total] binding in expenses-page template by
switching to GridDataResult object form ({ data, total }) on [data].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:59:25 -07:00
Chris Chen f5ff03260b feat(expense): add monthly reconciliation statement page
Implements Task 16 — MonthlyStatementPageComponent with Kendo Grid list
(year filter), create/edit dialog (server-computed totals preview), and
finalize action that locks the statement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:56:14 -07:00
Chris Chen aa77f2051a feat(expense): add finance expenses overview + review page 2026-05-29 18:53:39 -07:00
Chris Chen 4704d33b4a fix(expense): authenticated receipt download + correct delete confirm
The receipt <a href target=_blank> was an unauthenticated browser navigation
that the API's [Authorize] rejects with 401. Replace with a HttpClient blob
download (downloadReceipt) so the auth interceptor attaches the JWT, opened
via an object URL. Also fix the delete button: confirm() must run inside the
component method (matching givings-page), not as a template expression where
confirm is not a component member.
2026-05-29 18:51:04 -07:00
Chris Chen 18b9707e44 feat(expense): add member self-service My Reimbursements page
Standalone Angular component (Kendo Grid + ExpenseFormDialog) that lets
any logged-in user list, create, submit, and delete their own draft
reimbursements, with optional receipt upload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:48:42 -07:00
Chris Chen 1bb9da16d4 feat(expense): add reusable expense form dialog with category cascade
Standalone ExpenseFormDialogComponent with Ministry → Category Group → SubCategory
cascade, vendor/reimbursement modes, optional member picker (MemberApiService
search-as-you-type), and receipt file input. Emits CreateExpenseRequest + File.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:46:34 -07:00
Chris Chen 3188064335 feat(expense): add expense categories management page 2026-05-29 18:43:19 -07:00
Chris Chen 04b05617b8 feat(expense): add frontend models + API services
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:40:22 -07:00
Chris Chen 9933c180b7 feat(expense): add controllers + register services
Adds ExpenseCategoriesController, ExpensesController, MonthlyStatementsController
and registers IExpenseCategoryService, IExpenseService, IMonthlyStatementService in DI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:37:25 -07:00
Chris Chen 86d9879a6d feat(expense): add MonthlyStatementService with server-side recompute + tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:34:39 -07:00
Chris Chen d9289008f6 feat(expense): add ExpenseService with state machine + receipt storage + tests
TDD: wrote 8 tests first (red), then implemented IExpenseService + ExpenseService
covering CRUD, Draft→PendingApproval→Approved→Paid state machine, soft-delete,
per-owner access guards, and receipt blob round-trip via IFileStorage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:28:38 -07:00
Chris Chen 015f689d9b feat(expense): add ExpenseCategoryService + tests
TDD cycle: wrote 3 xUnit tests first (red), then implemented
IExpenseCategoryService + ExpenseCategoryService (green).
2026-05-29 18:24:07 -07:00
Chris Chen 15cdfe6f92 feat(expense): add expense, category, and monthly-statement DTOs 2026-05-29 18:21:52 -07:00
Chris Chen e7bf07c2ad feat(storage): add IFileStorage + local-disk implementation
Adds IFileStorage abstraction and LocalDiskFileStorage for receipt file storage with path-traversal protection, and registers it in DI. Includes 3 TDD-verified xUnit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:18:28 -07:00
Chris Chen ac65c68e18 feat(expense): add AddExpenseModule EF migration
Creates Ministries, ExpenseCategoryGroups, ExpenseSubCategories,
Expenses (with filtered Status index, MinistryId/ExpenseDate indexes,
Restrict FKs + SetNull on Member), and MonthlyStatements (unique
Year+Month index) tables. No existing tables modified.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:15:16 -07:00
Chris Chen cf929557fe feat(expense): add Expense + MonthlyStatement entities and EF config 2026-05-29 18:11:56 -07:00
Chris Chen cc58d06723 docs(expense): correct subcategory seed count to 39 (matches DB_SCHEMA §8) 2026-05-29 18:10:42 -07:00
Chris Chen b3eb9d297a feat(expense): add expense category entities + seed (11 groups / 38 subs)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:08:12 -07:00
Chris Chen f6f06d841c feat(ministry): add Ministry entity, seed (10), and read endpoint 2026-05-29 18:03:28 -07:00
Chris Chen 50e518095e docs(expense): add expense tracking implementation plan
18 TDD tasks: Ministry prerequisite (entity/seed/endpoint), expense category
entities + seed, Expense + MonthlyStatement entities, EF migration,
IFileStorage + local-disk impl, DTOs, three services with tests, controllers
with auth split, and four Angular pages + nav/routes wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:54:13 -07:00
Chris Chen fdd0d7c8e1 docs(expense): add expense tracking & reimbursement design spec
Covers all five PLANNING §3.6d items: category seed (11 groups/38 subs),
vendor direct payment, staff reimbursement with receipt upload + self-service
submission, finance approval workflow (Draft→PendingApproval→Approved→Paid),
and monthly reconciliation statement. Per DB_SCHEMA §8.

Key decisions: IFileStorage abstraction + local-disk impl for receipts
(Azure Blob deferred), member self-submission alongside finance entry,
soft-delete Expense, cash-basis (Paid-only) monthly expense totals.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:23:21 -07:00
Chris Chen 0639d1fe83 WIP 2026-05-28 22:29:13 -07:00
Chris Chen a2d394029a fix(giving): render Finance nav section in UserPortal sidebar
Task 11 added the finance nav to user-navbar.component, but the active
sidebar is rendered inline by UserPortalComponent (app-user-navbar is
not mounted). Added the role-gated Finance section (Offering Entry /
Givings / Giving Types) to UserPortalComponent, matching its
Administration pattern. Verified at runtime: section renders for
super_admin and links load the giving pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 18:22:50 -07:00
Chris Chen 5f8676f962 Merge feature/giving-module: manual giving/donation tracking module
Giving-category config, single giving entry, and keyboard-first Sunday
offering batch entry (OfferingSession) with server-side reconciliation,
lock-after-submit, and finance reopen/replace. 53 backend tests; runtime
E2E verified against dev DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 18:10:40 -07:00
Chris Chen 48885dba83 fix(giving): reset in-buffer line-edit index on reopen/cancel/submit 2026-05-28 17:44:43 -07:00
Chris Chen af21e50d9f feat(giving): add reopen-and-edit flow + recent sessions list to offering page 2026-05-28 17:41:19 -07:00
Chris Chen a573179714 feat(giving): match giver member name in single-giving search (spec §4.2)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:24:47 -07:00
Chris Chen 66640d1fd0 fix(giving): keep giver name visible when editing a buffered offering line 2026-05-28 17:18:40 -07:00
Chris Chen 001db35cef feat(giving): keyboard-first Sunday offering batch entry page + routes
- Add MemberQuickAddDialogComponent for fast in-session member creation
- Add OfferingSessionPageComponent: client-side buffer, reconcile totals, date-conflict check, submit to API
- Wire finance/giving-categories, finance/givings, finance/offering-session routes (RoleGuard: finance + super_admin)
- Fix givings-page: replace [total] + data[] with GridDataResult for Kendo v20 server-side paging

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:14:56 -07:00
Chris Chen 81a0b5a038 feat(giving): single giving entry page 2026-05-28 17:10:07 -07:00
Chris Chen 7260e5c115 feat(giving): giving categories management page
Add GivingCategoriesPageComponent — standalone Angular 18 component with Kendo Grid (list/deactivate) and Kendo Dialog (add/edit form) for managing giving types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:04:55 -07:00
Chris Chen 91247a7c69 feat(giving): add role-gated finance nav section 2026-05-28 17:00:49 -07:00
Chris Chen 4a2b142061 feat(giving): frontend models + API services
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:57:15 -07:00
Chris Chen b5a15dd9f2 feat(giving): add offering-sessions controller 2026-05-28 16:54:24 -07:00
Chris Chen 86041c0d05 fix(giving): map duplicate-date race to 409 + return zelle/paypal refs in session detail 2026-05-28 16:53:24 -07:00
Chris Chen e04776460d feat(giving): offering-session batch service with server-side totals + locking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:47:19 -07:00
Chris Chen 586551aec0 feat(giving): add givings controller 2026-05-28 16:43:06 -07:00
Chris Chen 8ff93e3698 test(giving): cover anonymous member-id stripping and delete-lock guard 2026-05-28 16:42:18 -07:00
Chris Chen 2b6f29e775 feat(giving): single-entry giving service with paging + lock guard
Adds GivingListItemDto, GivingDto, CreateGivingRequest, UpdateGivingRequest DTOs;
IGivingService interface; GivingService implementation with category/date filtering,
OfferingSession lock guard (Submitted/Reconciled), and DI registration in Program.cs.
Covered by 4 xUnit tests (TDD: red → green).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:38:32 -07:00
Chris Chen 81efaedbc2 feat(giving): add giving-categories controller 2026-05-28 16:34:18 -07:00
Chris Chen cb15d30980 refactor(giving): drop unused accessor from category service + add deactivate-missing test 2026-05-28 16:33:27 -07:00
Chris Chen 798dfa3fe0 feat(giving): giving-category service with CRUD + soft-disable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:29:31 -07:00
Chris Chen 8b52572fad feat(giving): add EF migration for giving module
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:25:11 -07:00
Chris Chen 577ae1aabe refactor(giving): use AnyAsync in category seed (code-review minor) 2026-05-28 16:21:32 -07:00
Chris Chen e20964ae0d feat(giving): seed default giving categories 2026-05-28 16:19:44 -07:00
Chris Chen 999f8a80f9 feat(giving): add GivingCategory, OfferingSession, Giving entities + EF config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:16:24 -07:00
Chris Chen 3974cec967 docs: add implementation plan for giving/donation tracking module
15 bite-sized TDD tasks: entities+EF+seed+migration, three services
(giving-category, single giving, offering-session) with server-side
totals and lock-after-submit, controllers, Angular models/services and
three pages (categories, single entry, keyboard-first batch entry +
quick-add member), role-gated finance nav, and E2E verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 15:57:47 -07:00
Chris Chen 82b9744024 docs: add design spec for giving/donation tracking module
Manual giving module (Phase 1): giving category config, single-entry
giving, and keyboard-first Sunday offering batch entry (OfferingSession)
with finance-gated reconciliation. Client-buffered bulk submit (decision
B), lock-after-submit, Member-or-anonymous givers with inline quick-add.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 15:47:09 -07:00
Chris Chen a525c71baa WIP 2026-05-28 15:25:31 -07:00
Chris Chen d79b1faa8f fix 401 loop hell 2026-05-27 15:09:05 -07:00
Chris Chen e83fa4c2e9 fix: use RandomNumberGenerator for cryptographic temp password generation
Replaced `new Random()` with `RandomNumberGenerator.GetInt32()` and a
Fisher-Yates shuffle to ensure temp passwords are cryptographically secure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:29:26 -07:00
Chris Chen bc67146d86 feat: add Administration section to sidebar with role-gated Member/User nav
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:26:07 -07:00
Chris Chen a18d44bd0a feat: add UsersPageComponent with Kendo Grid + edit/deactivate/reset-password
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:24:00 -07:00
Chris Chen 6c3292861a feat: add EditUserDialogComponent 2026-05-27 14:22:13 -07:00
Chris Chen 3a5b5721e4 feat: add MembersPageComponent with Kendo Grid and routing
Also adds stub UsersPageComponent for route compilation, and fixes
pre-existing kendo-textbox type="email" build errors in dialog templates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:20:52 -07:00
Chris Chen 07e0270599 feat: add CreateUserAccountDialogComponent with temp-password reveal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:17:15 -07:00
Chris Chen 32e47e4566 feat: add MemberFormDialogComponent (3-tab form)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:15:26 -07:00
Chris Chen d2eac52a47 feat: add Angular member and user models + API services
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:13:35 -07:00
Chris Chen 8249b3fe3e feat: add UsersController and register all services
Adds UsersController with CRUD endpoints (list, get, create, update,
deactivate, reset-password) restricted to super_admin role. Registers
IUserManagementService in Program.cs alongside existing services.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:10:46 -07:00
Chris Chen 3ab0998793 feat: add UserManagementService with temp-password creation and deactivation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:08:50 -07:00
Chris Chen 0986233d9b feat: add MembersController (CRUD + paged list)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:03:23 -07:00
Chris Chen bfffdee2a8 feat: add MemberService with soft-delete and paged search
Implements IMemberService with Create/Read/Update/soft-Delete operations,
NickName/zh-name search, status and hasUser filtering, and full xUnit coverage
(11 tests). Uses separate user-lookup query for InMemory DB compatibility; detaches
entity after soft-delete so query-filter assertions work correctly in tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:00:59 -07:00
Chris Chen 97743f6974 feat: add PagedResult, Member DTOs, and User DTOs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:55:21 -07:00
Chris Chen 34344cbf83 feat: add Member/FamilyUnit DbSets, audit interceptor registration, EF migration
Registers AuditSaveChangesInterceptor in DI and wires it into AppDbContext.
Adds Members and FamilyUnits DbSets with full column/index configuration and
applies the AddMemberAndFamilyUnit migration to the ChurchCRM database.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:52:58 -07:00
Chris Chen cd5413125d feat: add Member and FamilyUnit entities 2026-05-27 13:49:50 -07:00
Chris Chen 820ca6981c feat: add AuditSaveChangesInterceptor and failing interceptor tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:48:03 -07:00
Chris Chen f703519838 fix: use DateTimeOffset instead of DateTime in audit base classes 2026-05-27 13:46:31 -07:00
Chris Chen 5041873c2b feat: add AuditableEntity and SoftDeleteEntity base classes 2026-05-27 13:44:10 -07:00
Chris Chen 61c6697c87 docs: add 3-part implementation plan for Member and User Management
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:18:27 -07:00
Chris Chen 5d556b882d docs: add NickName field to Member spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:14:07 -07:00
Chris Chen adad5cb7e9 docs: add AspNetUsers CRUD and Member Management design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:12:07 -07:00
Chris Chen 60405ef0aa WIP 2026-05-27 07:49:26 -07:00
Chris Chen 62428cd2d4 feat: rewrite AuthService to use ROLAC auth API with in-memory token storage
- Replace GET /api/Token/Create (Basic Auth) with POST /api/Auth/login
- Add refresh() method using HttpOnly cookie (POST /api/Auth/refresh)
- Add initializeFromRefreshToken() for APP_INITIALIZER support
- logout() now fires POST /api/Auth/logout (fire-and-forget)
- Rename User interface to UserInfo (matches C# DTO: id, email, roles, languagePreference)
- All auth state is in-memory only (no localStorage)
- Fix downstream consumers: app.ts, header components, mfa-dialog, token-verification
- Fix tsconfig.spec.json: exclude legacy src/components and src/directives
- Add stub enums.model.ts and fix models/index.ts for pre-existing build errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:47:43 -07:00
Chris Chen 4874f2a0a3 test: fix spec issues from code review (logout assertion order, refresh 5xx test) 2026-05-26 20:38:58 -07:00
Chris Chen dc7909e247 test: add failing specs for AuthService login API integration 2026-05-26 20:34:49 -07:00
Chris Chen aa0c5403a1 docs: login API integration implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:29:33 -07:00
Chris Chen 98965274b8 docs: fix TokenVerificationResult type in login integration spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:22:24 -07:00
Chris Chen d1f342e3d0 docs: login API integration design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:21:43 -07:00
Chris Chen 2aa095c158 Task 11: Smoke test fixes (all 5 scenarios pass)
TokenService.GenerateRefreshToken():
  - Switched to URL-safe Base64 (RFC 4648 §5): +→-, /→_, no = padding.
  - Characters are unreserved per RFC 6265, so Response.Cookies.Append
    does NOT percent-encode the value.  Request.Cookies reads back exact value.

AuthController:
  - CookieOptions.Secure = !env.IsDevelopment()
    Plain HTTP in local dev works; HTTPS-only in staging/production.
  - Inject IWebHostEnvironment for environment-aware Secure flag.

TokenServiceTests:
  - Updated GenerateRefreshToken test: 86-char URL-safe Base64 instead
    of 64-byte standard Base64.  16/16 tests pass.

Smoke test results (http://localhost:5209):
  1. POST /api/auth/login       → 200 + rolac_rt cookie + JWT
  2. POST /api/auth/refresh     → 200 + new token (rotation)
  3. POST /api/auth/logout      → 204 + cookie cleared
  4. Refresh with revoked token → 401
  5. Wrong password             → 401

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 19:28:20 -07:00
Chris Chen ef0098d5cc Task 10: EF Core InitialAuth migration
Applied against ChurchCRM database on 192.168.68.55:49154.
Creates Identity tables (AspNetUsers, AspNetRoles, AspNetUserRoles, etc.)
+ RefreshTokens table with unique index on TokenHash.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 19:13:30 -07:00
Chris Chen 8b86bd573e Tasks 7-9: AuthController, appsettings, Program.cs
Task 7 – AuthController (POST /api/auth/login|refresh|logout)
  - Refresh token in HttpOnly; Secure; SameSite=Strict cookie (rolac_rt)
  - Cookie Path scoped to /api/auth; cleared on logout/invalid refresh

Task 8 – appsettings.json (non-secret JWT values + CORS origins)
  - appsettings.Development.json carries connection string + JWT secret
    (file is gitignored)

Task 9 – Program.cs wiring
  - EF Core + Npgsql, ASP.NET Core Identity, JWT Bearer auth
  - RoleClaimType=role matches the short JWT claim name written by TokenService
  - CORS: AllowCredentials for Angular app
  - Swagger UI with Bearer security definition
  - Startup: MigrateAsync + DbSeeder.SeedAsync (roles + dev admin)
  - DbSeeder: added SeedAsync(IServiceProvider) entry point

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:40:52 -07:00
Chris Chen 9db8b34181 Task 6: AuthService + 9 unit tests (16/16 pass)
- IAuthService: LoginAsync / RefreshAsync / LogoutAsync
- AuthService: refresh-token rotation, hashed storage, LastLoginAt update
- AuthServiceTests: 5 login + 3 refresh + 1 logout tests via Moq + EF InMemory

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:38:56 -07:00
Chris Chen f74563bb36 Task 5: TokenService + unit tests (7/7 pass)
- ITokenService: GenerateAccessToken / GenerateRefreshToken / HashToken
- TokenService: JWT (HS256, 15-min), 64-byte CSPRNG refresh, SHA-256 hex hash
  - Role claims use short JWT name role (v7.x JsonWebTokenHandler compatible)
- TokenServiceTests: 7 xUnit tests, payload decoded via Base64Url+System.Text.Json
  to avoid Microsoft.IdentityModel 7.1.2/7.5.2 version-mismatch issues

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:34:56 -07:00
Chris Chen b335867b30 feat: add LoginRequest and LoginResponse DTOs 2026-05-25 19:07:36 -07:00
Chris Chen a66a3f7cb0 feat: add AppDbContext (Identity + RefreshTokens) and DbSeeder (13 roles + dev admin) 2026-05-25 19:05:02 -07:00
Chris Chen 40d740d6e0 feat: add AppUser, AppRole, RefreshToken entities 2026-05-25 19:02:22 -07:00
Chris Chen 5a789fb0c2 chore: add Identity, EF Core PostgreSQL, JWT Bearer packages 2026-05-25 19:00:30 -07:00
Chris Chen cab4c6778f docs: add Login API implementation plan (JWT + ASP.NET Identity) 2026-05-25 18:57:18 -07:00
Chris Chen 4da8806bfc Init API 2026-05-25 17:38:23 -07:00
Chris Chen d5648315a0 WIP 2026-05-25 17:32:18 -07:00
634 changed files with 82739 additions and 29 deletions
+85
View File
@@ -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
+92
View File
@@ -0,0 +1,92 @@
name: ci-cd
on:
push:
branches: [azure-deploy]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '8.0.x' }
- run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release
build-push:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: git.golife.love
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: ./API
push: true
tags: |
git.golife.love/chrischen/rolac-api:latest
git.golife.love/chrischen/rolac-api:${{ github.sha }}
- uses: docker/build-push-action@v6
with:
context: ./APP
push: true
tags: |
git.golife.love/chrischen/rolac-app:latest
git.golife.love/chrischen/rolac-app:${{ github.sha }}
deploy:
needs: build-push
runs-on: ubuntu-latest
steps:
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VM_HOST }}
username: ${{ secrets.VM_USER }}
key: ${{ secrets.VM_SSH_KEY }}
script: |
cd /opt/rolac/deploy
export TAG=${{ github.sha }}
docker compose pull
docker compose up -d
curl -fsS https://manage.rolac.org/api/health
# Always runs (success or failure) so the team gets a build result in Rocket.Chat.
# A failed or skipped upstream job (skipped means an earlier job failed) reports as failed.
notify:
needs: [test, build-push, deploy]
if: always()
runs-on: ubuntu-latest
steps:
- name: Notify Rocket.Chat
env:
BUILD_FAILED: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }}
REPO: ${{ github.repository }}
REF: ${{ github.ref_name }}
SHA: ${{ github.sha }}
ACTOR: ${{ github.actor }}
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}
WEBHOOK: ${{ secrets.ROCKETCHAT_WEBHOOK }}
run: |
if [ "$BUILD_FAILED" = "true" ]; then
STATUS_TEXT="❌ Build failed"
COLOR="#e74c3c"
else
STATUS_TEXT="✅ Build succeeded"
COLOR="#2ecc71"
fi
SHORT_SHA="${SHA:0:7}"
curl -fsS -X POST -H 'Content-Type: application/json' --data @- "$WEBHOOK" <<JSON
{
"attachments": [
{
"title": "$REPO — $STATUS_TEXT",
"title_link": "$COMMIT_URL",
"color": "$COLOR",
"text": "Branch *$REF* · commit $SHORT_SHA · by $ACTOR"
}
]
}
JSON
+3
View File
@@ -91,3 +91,6 @@ logs/
*.log
*.tmp
*.temp
/.claude
/API/ROLAC.API/bin-verify
API/ROLAC.API/App_Data/
+7
View File
@@ -0,0 +1,7 @@
**/bin
**/obj
**/appsettings.Development.json
**/appsettings.*.local.json
ROLAC.API.Tests/
.git
*.user
+35
View File
@@ -0,0 +1,35 @@
# ---- build ----
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# nuget.config carries the DevExpress licensed feed so restore resolves 24.1.3
# (the public feed only offers the trial 25.x, which renamed TableBorderLineStyle).
COPY nuget.config ./
COPY ROLAC.API/ROLAC.API.csproj ROLAC.API/
RUN dotnet restore ROLAC.API/ROLAC.API.csproj --configfile nuget.config
COPY ROLAC.API/ ROLAC.API/
RUN dotnet publish ROLAC.API/ROLAC.API.csproj -c Release -o /app/publish /p:UseAppHost=false --no-restore
# ---- runtime ----
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
ENV ASPNETCORE_ENVIRONMENT=Production \
ASPNETCORE_HTTP_PORTS=8080
# curl: used by the HEALTHCHECK (not present in the base image)
# libfontconfig1 + fonts: required by DevExpress.Drawing's Skia backend for PDF text
# rendering. fonts-noto-cjk supplies the Chinese glyphs used in the receipt PDF.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
libfontconfig1 \
fontconfig \
fonts-dejavu \
fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/publish .
# storage dir created + owned for the non-root app user
RUN mkdir -p /app/App_Data/storage && chown -R app:app /app/App_Data
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s \
CMD curl -fsS http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "ROLAC.API.dll"]
@@ -0,0 +1,64 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Moq;
using ROLAC.API.Authorization;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Authorization;
public class PermissionAuthorizationHandlerTests
{
private static ClaimsPrincipal UserWithRoles(params string[] roles)
{
var claims = roles.Select(role => new Claim("role", role));
return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: "test"));
}
private static async Task<bool> EvaluateAsync(
ClaimsPrincipal user, PermissionRequirement requirement, IPermissionService permissions)
{
var handler = new PermissionAuthorizationHandler(permissions);
var context = new AuthorizationHandlerContext([requirement], user, resource: null);
await handler.HandleAsync(context);
return context.HasSucceeded;
}
[Fact]
public async Task SuperAdmin_AlwaysSucceeds_WithoutConsultingMatrix()
{
var permissions = new Mock<IPermissionService>(MockBehavior.Strict); // must NOT be called
var requirement = new PermissionRequirement(Modules.Members, PermissionActions.Delete);
var succeeded = await EvaluateAsync(UserWithRoles("super_admin"), requirement, permissions.Object);
Assert.True(succeeded);
permissions.Verify(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task RoleWithPermission_Succeeds()
{
var permissions = new Mock<IPermissionService>();
permissions.Setup(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), Modules.Members, PermissionActions.Write))
.ReturnsAsync(true);
var requirement = new PermissionRequirement(Modules.Members, PermissionActions.Write);
var succeeded = await EvaluateAsync(UserWithRoles("secretary"), requirement, permissions.Object);
Assert.True(succeeded);
}
[Fact]
public async Task RoleWithoutPermission_Fails()
{
var permissions = new Mock<IPermissionService>();
permissions.Setup(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(false);
var requirement = new PermissionRequirement(Modules.Givings, PermissionActions.Write);
var succeeded = await EvaluateAsync(UserWithRoles("member"), requirement, permissions.Object);
Assert.False(succeeded);
}
}
@@ -0,0 +1,62 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using Xunit;
namespace ROLAC.API.Tests.Data;
public class AuditInterceptorTests
{
private static AppDbContext BuildDb(AuditSaveChangesInterceptor interceptor)
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(interceptor)
.Options;
return new AppDbContext(options);
}
private static AuditSaveChangesInterceptor BuildInterceptor(string userId = "user-1")
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object));
}
[Fact]
public async Task Added_SetsCreatedAtAndCreatedBy()
{
var interceptor = BuildInterceptor("user-42");
using var db = BuildDb(interceptor);
var member = new Member { FirstName_en = "A", LastName_en = "B" };
db.Members.Add(member);
await db.SaveChangesAsync();
Assert.Equal("user-42", member.CreatedBy);
Assert.Equal("user-42", member.UpdatedBy);
Assert.True(member.CreatedAt > DateTimeOffset.UtcNow.AddSeconds(-5));
}
[Fact]
public async Task Modified_UpdatesUpdatedAtAndUpdatedBy()
{
var interceptor = BuildInterceptor("user-1");
using var db = BuildDb(interceptor);
var member = new Member { FirstName_en = "A", LastName_en = "B" };
db.Members.Add(member);
await db.SaveChangesAsync();
member.NickName = "Nick";
await db.SaveChangesAsync();
Assert.Equal("user-1", member.UpdatedBy);
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ROLAC.API\ROLAC.API.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,29 @@
using ROLAC.API.Services.Disbursement;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class AmountToWordsTests
{
[Theory]
[InlineData(0, "Zero and 00/100 Dollars")]
[InlineData(0.05, "Zero and 05/100 Dollars")]
[InlineData(1, "One and 00/100 Dollars")]
[InlineData(19, "Nineteen and 00/100 Dollars")]
[InlineData(20, "Twenty and 00/100 Dollars")]
[InlineData(21, "Twenty-One and 00/100 Dollars")]
[InlineData(100, "One Hundred and 00/100 Dollars")]
[InlineData(115, "One Hundred Fifteen and 00/100 Dollars")]
[InlineData(1234.56, "One Thousand Two Hundred Thirty-Four and 56/100 Dollars")]
[InlineData(1000000, "One Million and 00/100 Dollars")]
public void Convert_FormatsExpectedWords(double amount, string expected)
{
Assert.Equal(expected, AmountToWords.Convert((decimal)amount));
}
[Fact]
public void Convert_RoundsCentsHalfUp()
{
Assert.Equal("One and 00/100 Dollars", AmountToWords.Convert(0.999m));
}
}
@@ -0,0 +1,354 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Auth;
using ROLAC.API.DTOs.Permissions;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class AuthServiceTests
{
// -----------------------------------------------------------------------
// Factory helpers
// -----------------------------------------------------------------------
private static AppDbContext BuildDb() =>
new(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
private static IConfiguration BuildConfig() =>
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "Jwt:RefreshTokenExpiryDays", "30" },
})
.Build();
/// <summary>Creates a <see cref="UserManager{TUser}"/> mock with sensible defaults.</summary>
private static Mock<UserManager<AppUser>> BuildUserManager(
AppUser? findResult = null,
bool passwordOk = true,
IList<string>? roles = null,
IdentityResult? changePasswordResult = null)
{
var store = new Mock<IUserStore<AppUser>>();
// Remaining ctor params are all optional; Moq passes them via reflection.
#pragma warning disable CS8625
var mgr = new Mock<UserManager<AppUser>>(
store.Object, null, null, null, null, null, null, null, null);
#pragma warning restore CS8625
mgr.Setup(m => m.FindByEmailAsync(It.IsAny<string>()))
.ReturnsAsync(findResult);
mgr.Setup(m => m.FindByIdAsync(It.IsAny<string>()))
.ReturnsAsync(findResult);
mgr.Setup(m => m.CheckPasswordAsync(It.IsAny<AppUser>(), It.IsAny<string>()))
.ReturnsAsync(passwordOk);
mgr.Setup(m => m.GetRolesAsync(It.IsAny<AppUser>()))
.ReturnsAsync(roles ?? new List<string> { "member" });
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
.ReturnsAsync(IdentityResult.Success);
mgr.Setup(m => m.ChangePasswordAsync(
It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(changePasswordResult ?? IdentityResult.Success);
return mgr;
}
/// <summary>
/// ITokenService mock: GenerateAccessToken → "access-token",
/// GenerateRefreshToken → "raw-refresh", HashToken(x) → "hash:{x}".
/// </summary>
private static Mock<ITokenService> BuildTokenService()
{
var svc = new Mock<ITokenService>();
svc.Setup(t => t.GenerateAccessToken(It.IsAny<AppUser>(), It.IsAny<IList<string>>()))
.Returns("access-token");
svc.Setup(t => t.GenerateRefreshToken())
.Returns("raw-refresh");
svc.Setup(t => t.HashToken(It.IsAny<string>()))
.Returns<string>(s => $"hash:{s}");
return svc;
}
/// <summary>IPermissionService mock: returns an empty effective-permission map.</summary>
private static Mock<IPermissionService> BuildPermissionService()
{
var svc = new Mock<IPermissionService>();
svc.Setup(p => p.GetEffectivePermissionsAsync(It.IsAny<IEnumerable<string>>()))
.ReturnsAsync(new Dictionary<string, ModuleActions>());
return svc;
}
private static AuthService BuildSut(
Mock<UserManager<AppUser>> umMock,
Mock<ITokenService> tsMock,
AppDbContext db)
=> new(umMock.Object, tsMock.Object, db, BuildPermissionService().Object,
ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance, BuildConfig());
// -----------------------------------------------------------------------
// Login tests
// -----------------------------------------------------------------------
[Fact]
public async Task Login_ValidCredentials_ReturnsAccessTokenAndRefreshToken()
{
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
var um = BuildUserManager(findResult: user, roles: new[] { "member" });
var ts = BuildTokenService();
var db = BuildDb();
var sut = BuildSut(um, ts, db);
var (response, raw) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });
Assert.Equal("access-token", response.AccessToken);
Assert.Equal(900, response.ExpiresIn);
Assert.Equal("u1", response.User.Id);
Assert.Equal("a@b.com", response.User.Email);
Assert.Contains("member", response.User.Roles);
Assert.Equal("raw-refresh", raw);
// Persisted in DB
var stored = db.RefreshTokens.Single();
Assert.Equal("hash:raw-refresh", stored.TokenHash);
Assert.Equal("u1", stored.UserId);
}
[Fact]
public async Task Login_UnknownEmail_ThrowsUnauthorized()
{
var um = BuildUserManager(findResult: null);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => sut.LoginAsync(new LoginRequest { Email = "nope@b.com", Password = "P@ssw0rd!" }));
}
[Fact]
public async Task Login_WrongPassword_ThrowsUnauthorized()
{
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
var um = BuildUserManager(findResult: user, passwordOk: false);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "wrong" }));
}
[Fact]
public async Task Login_InactiveAccount_ThrowsUnauthorized()
{
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = false };
var um = BuildUserManager(findResult: user, passwordOk: true);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" }));
}
[Fact]
public async Task Login_Success_UpdatesLastLoginAt()
{
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());
await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });
um.Verify(m => m.UpdateAsync(It.Is<AppUser>(u => u.LastLoginAt != null)), Times.Once);
}
// -----------------------------------------------------------------------
// Refresh tests
// -----------------------------------------------------------------------
[Fact]
public async Task Refresh_ValidToken_RotatesRefreshToken()
{
const string rawOld = "old-raw";
const string rawNew = "raw-refresh"; // what BuildTokenService returns
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 db = BuildDb();
// Seed an active token
db.RefreshTokens.Add(new RefreshToken
{
UserId = "u1",
TokenHash = "hash:old-raw",
ExpiresAt = DateTime.UtcNow.AddDays(30),
CreatedAt = DateTime.UtcNow.AddHours(-1),
});
await db.SaveChangesAsync();
var sut = BuildSut(um, ts, db);
var (response, newRaw) = await sut.RefreshAsync(rawOld);
Assert.Equal("access-token", response.AccessToken);
Assert.Equal(rawNew, newRaw);
// Old token revoked
var old = db.RefreshTokens.First(rt => rt.TokenHash == "hash:old-raw");
Assert.NotNull(old.RevokedAt);
Assert.Equal("hash:raw-refresh", old.ReplacedByHash);
// New token stored
var newTok = db.RefreshTokens.First(rt => rt.TokenHash == "hash:raw-refresh");
Assert.Null(newTok.RevokedAt);
}
[Fact]
public async Task Refresh_UnknownToken_ThrowsUnauthorized()
{
var um = BuildUserManager();
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => sut.RefreshAsync("no-such-token"));
}
[Fact]
public async Task Refresh_ExpiredToken_ThrowsUnauthorized()
{
var db = BuildDb();
db.RefreshTokens.Add(new RefreshToken
{
UserId = "u1",
TokenHash = "hash:expired-raw",
ExpiresAt = DateTime.UtcNow.AddDays(-1), // already expired
CreatedAt = DateTime.UtcNow.AddDays(-31),
});
await db.SaveChangesAsync();
var um = BuildUserManager();
var ts = BuildTokenService();
var sut = BuildSut(um, ts, db);
await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => sut.RefreshAsync("expired-raw"));
}
// -----------------------------------------------------------------------
// Logout tests
// -----------------------------------------------------------------------
[Fact]
public async Task Logout_ValidToken_RevokesRefreshToken()
{
const string raw = "my-raw-token";
var db = BuildDb();
db.RefreshTokens.Add(new RefreshToken
{
UserId = "u1",
TokenHash = "hash:my-raw-token",
ExpiresAt = DateTime.UtcNow.AddDays(30),
CreatedAt = DateTime.UtcNow.AddHours(-1),
});
await db.SaveChangesAsync();
var um = BuildUserManager();
var ts = BuildTokenService();
var sut = BuildSut(um, ts, db);
await sut.LogoutAsync(raw);
var token = db.RefreshTokens.Single();
Assert.NotNull(token.RevokedAt);
}
// -----------------------------------------------------------------------
// Change password tests
// -----------------------------------------------------------------------
[Fact]
public async Task ChangePassword_ValidRequest_Succeeds()
{
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
var um = BuildUserManager(findResult: user);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
var result = await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", null);
Assert.True(result.Succeeded);
um.Verify(m => m.ChangePasswordAsync(user, "Old1234!", "New1234!"), Times.Once);
}
[Fact]
public async Task ChangePassword_UnknownUser_Fails()
{
var um = BuildUserManager(findResult: null);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
var result = await sut.ChangePasswordAsync("missing", "Old1234!", "New1234!", null);
Assert.False(result.Succeeded);
um.Verify(m => m.ChangePasswordAsync(
It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task ChangePassword_WrongCurrentPassword_ReturnsFailure()
{
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
var failed = IdentityResult.Failed(new IdentityError { Description = "Incorrect password." });
var um = BuildUserManager(findResult: user, changePasswordResult: failed);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
var result = await sut.ChangePasswordAsync("u1", "WrongOld!", "New1234!", null);
Assert.False(result.Succeeded);
}
[Fact]
public async Task ChangePassword_Success_RevokesOtherSessionsButKeepsCurrent()
{
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
var um = BuildUserManager(findResult: user);
var ts = BuildTokenService(); // HashToken(x) => "hash:{x}"
var db = BuildDb();
// Current session token (raw "current-raw" => "hash:current-raw")
db.RefreshTokens.Add(new RefreshToken
{
UserId = "u1",
TokenHash = "hash:current-raw",
ExpiresAt = DateTime.UtcNow.AddDays(30),
CreatedAt = DateTime.UtcNow.AddHours(-1),
});
// Another active session on a different device
db.RefreshTokens.Add(new RefreshToken
{
UserId = "u1",
TokenHash = "hash:other-device",
ExpiresAt = DateTime.UtcNow.AddDays(30),
CreatedAt = DateTime.UtcNow.AddHours(-2),
});
await db.SaveChangesAsync();
var sut = BuildSut(um, ts, db);
await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", "current-raw");
var current = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:current-raw");
var other = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:other-device");
Assert.Null(current.RevokedAt); // current session preserved
Assert.NotNull(other.RevokedAt); // other session revoked
}
}
@@ -0,0 +1,271 @@
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using ROLAC.API.Services.Disbursement;
using ROLAC.API.Services.Storage;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class DisbursementServiceTests
{
private sealed class FakeStorage : IFileStorage
{
public Dictionary<string, byte[]> Files = new();
public Task<string> SaveAsync(Stream c, string p, CancellationToken ct = default)
{ using var ms = new MemoryStream(); c.CopyTo(ms); Files[p] = ms.ToArray(); return Task.FromResult(p); }
public Task<Stream?> OpenReadAsync(string p, CancellationToken ct = default)
=> Task.FromResult<Stream?>(Files.TryGetValue(p, out var b) ? new MemoryStream(b) : null);
public Task DeleteAsync(string p, CancellationToken ct = default) { Files.Remove(p); return Task.CompletedTask; }
}
private sealed class FakePrint : ICheckPrintService
{
public CheckPrintModel? LastReceiptModel;
public Task<Stream> RenderPdfAsync(CheckPrintModel model)
=> Task.FromResult<Stream>(new MemoryStream(Encoding.UTF8.GetBytes("pdf")));
public Task<Stream> RenderReceiptPdfAsync(CheckPrintModel model)
{
LastReceiptModel = model;
return Task.FromResult<Stream>(new MemoryStream(Encoding.UTF8.GetBytes("receipt-pdf")));
}
}
private static AppDbContext BuildDb(string userId)
{
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
}
private static DisbursementService SvcAs(AppDbContext db, FakeStorage fs, string userId)
{
var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx);
return new DisbursementService(db, http.Object, fs, new FakePrint(), ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
}
private static (DisbursementService svc, AppDbContext db, FakeStorage fs) Build(string userId = "fin")
{
var db = BuildDb(userId);
db.ChurchProfiles.Add(new ChurchProfile { Id = 1, Name = "ROLAC", NextCheckNumber = 1001 });
db.Members.Add(new Member { Id = 1, FirstName_en = "John", LastName_en = "Doe", Address = "1 Main St", City = "Arcadia", State = "CA", ZipCode = "91006" });
db.SaveChanges();
var fs = new FakeStorage();
return (SvcAs(db, fs, userId), db, fs);
}
private static Expense Approved(string type, decimal amount, int? memberId = null, string? vendor = null) => new()
{
Type = type, Status = "Approved", Amount = amount, Description = $"{type} {amount}",
MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, ExpenseDate = new DateOnly(2026, 6, 1),
MemberId = memberId, VendorName = vendor,
};
[Fact]
public async Task GroupedWorklist_BundlesSamePayee()
{
var (svc, db, _) = Build();
db.Expenses.AddRange(
Approved("StaffReimbursement", 10m, memberId: 1),
Approved("StaffReimbursement", 15m, memberId: 1),
Approved("VendorPayment", 30m, vendor: "Acme"));
await db.SaveChangesAsync();
var groups = await svc.GetApprovedUnpaidGroupedAsync();
Assert.Equal(2, groups.Count);
var member = groups.Single(g => g.PayeeType == "Member");
Assert.Equal(25m, member.TotalAmount);
Assert.Equal(2, member.Lines.Count);
Assert.Equal("John Doe", member.PayeeName);
Assert.Equal("1 Main St", member.Address);
}
[Fact]
public async Task Issue_CreatesOneCheckPerPayee_MarksPaid_SequentialNumbers()
{
var (svc, db, _) = Build();
var e1 = Approved("StaffReimbursement", 10m, memberId: 1);
var e2 = Approved("StaffReimbursement", 15m, memberId: 1);
var e3 = Approved("VendorPayment", 30m, vendor: "Acme");
db.Expenses.AddRange(e1, e2, e3);
await db.SaveChangesAsync();
var req = new IssueChecksRequest
{
CheckDate = new DateOnly(2026, 6, 20),
Payees =
[
new() { PayeeType = "Member", MemberId = 1, PayeeName = "John Doe", ExpenseIds = [e1.Id, e2.Id] },
new() { PayeeType = "Vendor", VendorKey = "acme", PayeeName = "Acme", ExpenseIds = [e3.Id] },
],
};
var result = await svc.IssueChecksAsync(req);
Assert.Equal(2, result.Created.Count);
Assert.Equal(new[] { "1001", "1002" }, result.Created.Select(c => c.CheckNumber).ToArray());
Assert.All(await db.Expenses.ToListAsync(), e => Assert.Equal("Paid", e.Status));
var memberCheck = await db.Checks.FirstAsync(c => c.PayeeType == "Member");
Assert.Equal(25m, memberCheck.Amount);
Assert.Equal(1003, (await db.ChurchProfiles.FirstAsync()).NextCheckNumber);
}
[Fact]
public async Task Issue_RejectsNonApprovedExpense()
{
var (svc, db, _) = Build();
var e = Approved("VendorPayment", 30m, vendor: "Acme");
e.Status = "Draft";
db.Expenses.Add(e);
await db.SaveChangesAsync();
var req = new IssueChecksRequest
{
CheckDate = new DateOnly(2026, 6, 20),
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
};
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.IssueChecksAsync(req));
}
[Fact]
public async Task Void_RevertsExpensesToApproved()
{
var (svc, db, _) = Build();
var e = Approved("VendorPayment", 30m, vendor: "Acme");
db.Expenses.Add(e);
await db.SaveChangesAsync();
var result = await svc.IssueChecksAsync(new IssueChecksRequest
{
CheckDate = new DateOnly(2026, 6, 20),
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
});
var checkId = result.Created[0].CheckId;
await svc.VoidAsync(checkId, "wrong amount");
var check = await db.Checks.FirstAsync(c => c.Id == checkId);
Assert.Equal("Voided", check.Status);
var reverted = await db.Expenses.FirstAsync(x => x.Id == e.Id);
Assert.Equal("Approved", reverted.Status);
Assert.Null(reverted.CheckNumber);
Assert.Null(reverted.PaidAt);
}
[Fact]
public async Task Acknowledge_StoresSignatureAndTimestamp()
{
var (svc, db, fs) = Build();
var e = Approved("VendorPayment", 30m, vendor: "Acme");
db.Expenses.Add(e);
await db.SaveChangesAsync();
var result = await svc.IssueChecksAsync(new IssueChecksRequest
{
CheckDate = new DateOnly(2026, 6, 20),
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
});
var checkId = result.Created[0].CheckId;
using var img = new MemoryStream(Encoding.UTF8.GetBytes("png-bytes"));
await svc.AcknowledgeReceiptAsync(checkId, img, "sig.png", "Acme Rep");
var check = await db.Checks.FirstAsync(c => c.Id == checkId);
Assert.NotNull(check.ReceiptSignedAt);
Assert.Equal("Acme Rep", check.ReceiptSignedName);
Assert.NotNull(check.ReceiptSignatureBlobPath);
Assert.Single(fs.Files);
}
private static (DisbursementService svc, AppDbContext db, FakeStorage fs, FakePrint print) BuildWithPrint(string userId = "fin")
{
var db = BuildDb(userId);
db.ChurchProfiles.Add(new ChurchProfile { Id = 1, Name = "ROLAC", NextCheckNumber = 1001 });
db.SaveChanges();
var fs = new FakeStorage();
var print = new FakePrint();
var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx);
return (new DisbursementService(db, http.Object, fs, print, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance), db, fs, print);
}
[Fact]
public async Task ReceiptPdf_NullWhenNotSigned()
{
var (svc, db, _, _) = BuildWithPrint();
var e = Approved("VendorPayment", 30m, vendor: "Acme");
db.Expenses.Add(e);
await db.SaveChangesAsync();
var result = await svc.IssueChecksAsync(new IssueChecksRequest
{
CheckDate = new DateOnly(2026, 6, 20),
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
});
var receipt = await svc.RenderReceiptPdfAsync(result.Created[0].CheckId);
Assert.Null(receipt);
}
[Fact]
public async Task ReceiptPdf_AfterSigning_RendersWithSignatureBytes()
{
var (svc, db, _, print) = BuildWithPrint();
var e = Approved("VendorPayment", 30m, vendor: "Acme");
db.Expenses.Add(e);
await db.SaveChangesAsync();
var result = await svc.IssueChecksAsync(new IssueChecksRequest
{
CheckDate = new DateOnly(2026, 6, 20),
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
});
var checkId = result.Created[0].CheckId;
using var img = new MemoryStream(Encoding.UTF8.GetBytes("png-bytes"));
await svc.AcknowledgeReceiptAsync(checkId, img, "sig.png", "Acme Rep");
var receipt = await svc.RenderReceiptPdfAsync(checkId);
Assert.NotNull(receipt);
Assert.Equal("receipt-1001.pdf", receipt!.Value.fileName);
Assert.NotNull(print.LastReceiptModel);
Assert.NotNull(print.LastReceiptModel!.SignatureImage);
Assert.Equal("Acme Rep", print.LastReceiptModel.Check.ReceiptSignedName);
}
[Fact]
public async Task Acknowledge_VoidedCheck_Throws()
{
var (svc, db, _) = Build();
var e = Approved("VendorPayment", 30m, vendor: "Acme");
db.Expenses.Add(e);
await db.SaveChangesAsync();
var result = await svc.IssueChecksAsync(new IssueChecksRequest
{
CheckDate = new DateOnly(2026, 6, 20),
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
});
var checkId = result.Created[0].CheckId;
await svc.VoidAsync(checkId, null);
using var img = new MemoryStream(Encoding.UTF8.GetBytes("png"));
await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.AcknowledgeReceiptAsync(checkId, img, "sig.png", "X"));
}
}
@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class ExpenseCategoryServiceTests
{
private static AppDbContext BuildDb()
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
}
[Fact]
public async Task GetAll_NestsSubcategories_AndExcludesInactiveByDefault()
{
using var db = BuildDb();
var svc = new ExpenseCategoryService(db);
var gid = await svc.CreateGroupAsync(new CreateExpenseGroupRequest { Name_en = "Equipment" });
var sid = await svc.CreateSubCategoryAsync(new CreateExpenseSubCategoryRequest { GroupId = gid, Name_en = "Purchase" });
await svc.DeactivateSubCategoryAsync(sid);
var active = await svc.GetAllAsync(includeInactive: false);
var all = await svc.GetAllAsync(includeInactive: true);
Assert.Single(active);
Assert.Empty(active[0].SubCategories);
Assert.Single(all[0].SubCategories);
}
[Fact]
public async Task DeactivateGroup_SetsInactive()
{
using var db = BuildDb();
var svc = new ExpenseCategoryService(db);
var gid = await svc.CreateGroupAsync(new CreateExpenseGroupRequest { Name_en = "Other" });
await svc.DeactivateGroupAsync(gid);
Assert.Empty(await svc.GetAllAsync(includeInactive: false));
}
[Fact]
public async Task UpdateGroup_Throws_WhenMissing()
{
using var db = BuildDb();
var svc = new ExpenseCategoryService(db);
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
svc.UpdateGroupAsync(999, new UpdateExpenseGroupRequest { Name_en = "X" }));
}
}
@@ -0,0 +1,261 @@
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Http;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using ROLAC.API.Services.Storage;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class ExpenseServiceTests
{
private sealed class FakeStorage : IFileStorage
{
public Dictionary<string, byte[]> Files = new();
public Task<string> SaveAsync(Stream c, string p, CancellationToken ct = default)
{ using var ms = new MemoryStream(); c.CopyTo(ms); Files[p] = ms.ToArray(); return Task.FromResult(p); }
public Task<Stream?> OpenReadAsync(string p, CancellationToken ct = default)
=> Task.FromResult<Stream?>(Files.TryGetValue(p, out var b) ? new MemoryStream(b) : null);
public Task DeleteAsync(string p, CancellationToken ct = default) { Files.Remove(p); return Task.CompletedTask; }
}
private static AppDbContext BuildDb(string userId)
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
}
private static (ExpenseService svc, AppDbContext db, FakeStorage fs) Build(string userId = "u1")
{
var db = BuildDb(userId);
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Worship" });
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Equipment" });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Purchase" });
db.SaveChanges();
var fs = new FakeStorage();
return (SvcAs(db, fs, userId), db, fs);
}
private static ExpenseService SvcAs(AppDbContext db, FakeStorage fs, string userId)
{
var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx);
return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
}
// Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier),
// mirroring the real JWT (NameClaimType="sub", MapInboundClaims=false).
private static ExpenseService SvcWithSubClaim(AppDbContext db, FakeStorage fs, string userId)
{
var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim("sub", userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx);
return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
}
private static CreateExpenseRequest Reimb() => new()
{
Type = "StaffReimbursement", MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1,
Amount = 45.50m, Description = "Batteries", ExpenseDate = new DateOnly(2026, 5, 28),
};
private static UpdateExpenseRequest CloneToUpdate(CreateExpenseRequest r) => new()
{
Type = r.Type, MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId,
SubCategoryId = r.SubCategoryId, Amount = r.Amount, Description = r.Description,
VendorName = r.VendorName, MemberId = r.MemberId, CheckNumber = r.CheckNumber,
ExpenseDate = r.ExpenseDate, Notes = r.Notes,
};
[Fact]
public async Task Create_Reimbursement_ResolvesUserId_FromSubClaim()
{
// Regression: the real JWT exposes the user id as "sub", not ClaimTypes.NameIdentifier.
// SubmittedBy must be the sub value (not "system"), or the self-ownership guard breaks.
var db = BuildDb("ignored");
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Worship" });
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Equipment" });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Purchase" });
await db.SaveChangesAsync();
var svc = SvcWithSubClaim(db, new FakeStorage(), "user-guid-123");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
var e = await db.Expenses.FindAsync(id);
Assert.Equal("user-guid-123", e!.SubmittedBy);
}
[Fact]
public async Task Create_Vendor_AsFinance_IsPendingApproval()
{
var (svc, db, _) = Build();
var r = Reimb(); r.Type = "VendorPayment"; r.VendorName = "ABC"; r.CheckNumber = "2051";
var id = await svc.CreateAsync(r, isFinance: true);
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
}
[Fact]
public async Task Create_Reimbursement_AsFinance_OnBehalf_IsPendingApproval_AndLinksPickedMember()
{
// Finance entering on behalf of a member (member explicitly picked) goes straight to the
// approval queue and links the picked member.
var (svc, db, _) = Build();
db.Members.Add(new Member { Id = 9, FirstName_en = "Pat", LastName_en = "Vendor" });
await db.SaveChangesAsync();
var r = Reimb(); r.MemberId = 9;
var id = await svc.CreateAsync(r, isFinance: true);
var e = await db.Expenses.FindAsync(id);
Assert.Equal("PendingApproval", e!.Status);
Assert.Equal(9, e.MemberId);
}
[Fact]
public async Task Create_Reimbursement_AsFinance_SelfService_LinksCallerMember_AndIsDraft()
{
// Regression: a finance/super_admin user filing their OWN reimbursement via "My Reimbursements"
// sends no MemberId. The entry must link to the caller's own member (so the Payee shows their
// legal name) and stay a Draft until they explicitly Submit — not jump to PendingApproval with
// a null member.
var (svc, db, _) = Build("u1");
db.Members.Add(new Member { Id = 7, FirstName_en = "Grace", LastName_en = "Lee" });
db.Users.Add(new AppUser { Id = "u1", MemberId = 7 });
await db.SaveChangesAsync();
var id = await svc.CreateAsync(Reimb(), isFinance: true); // no MemberId on the request
var e = await db.Expenses.FindAsync(id);
Assert.Equal(7, e!.MemberId);
Assert.Equal("Draft", e.Status);
}
[Fact]
public async Task Create_Reimbursement_AsMember_IsDraft_WithSubmitter()
{
var (svc, db, _) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
var e = await db.Expenses.FindAsync(id);
Assert.Equal("Draft", e!.Status);
Assert.Equal("alice", e.SubmittedBy);
}
[Fact]
public async Task StateMachine_HappyPath_Submit_Approve_Pay()
{
var (svc, db, _) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
await svc.ApproveAsync(id);
Assert.Equal("Approved", (await db.Expenses.FindAsync(id))!.Status);
await svc.PayAsync(id, "3001", new DateOnly(2026, 6, 1));
var paid = await db.Expenses.FindAsync(id);
Assert.Equal("Paid", paid!.Status);
Assert.Equal("3001", paid.CheckNumber);
}
[Fact]
public async Task Approve_FromDraft_Throws()
{
var (svc, _, _) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.ApproveAsync(id));
}
[Fact]
public async Task Reject_RecordsNotes_AndStatus()
{
var (svc, db, _) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
await svc.RejectAsync(id, "Missing receipt");
var e = await db.Expenses.FindAsync(id);
Assert.Equal("Rejected", e!.Status);
Assert.Equal("Missing receipt", e.ReviewNotes);
}
[Fact]
public async Task Update_OthersDraft_AsNonFinance_Throws()
{
var (aliceSvc, db, fs) = Build("alice");
var id = await aliceSvc.CreateAsync(Reimb(), isFinance: false);
var bobSvc = SvcAs(db, fs, "bob");
await Assert.ThrowsAsync<InvalidOperationException>(() =>
bobSvc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false));
}
[Fact]
public async Task Update_OwnPendingApproval_AsNonFinance_Succeeds()
{
// After Submit a reimbursement sits in PendingApproval; the owner may still correct it.
var (svc, db, _) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
var edit = CloneToUpdate(Reimb());
edit.Amount = 99.99m;
await svc.UpdateAsync(id, edit, isFinance: false);
var e = await db.Expenses.FindAsync(id);
Assert.Equal(99.99m, e!.Amount);
Assert.Equal("PendingApproval", e.Status);
}
[Fact]
public async Task Update_OwnApproved_AsNonFinance_Throws()
{
// Once approved, the owner can no longer edit.
var (svc, db, fs) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
await SvcAs(db, fs, "finance").ApproveAsync(id);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false));
}
[Fact]
public async Task SaveReceipt_OwnPendingApproval_AsNonFinance_Succeeds()
{
var (svc, db, _) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
using var input = new MemoryStream(Encoding.UTF8.GetBytes("img"));
await svc.SaveReceiptAsync(id, input, "r.jpg", isFinance: false);
Assert.NotNull(await svc.OpenReceiptAsync(id, isFinance: true));
}
[Fact]
public async Task SoftDelete_HidesFromQueries()
{
var (svc, db, _) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.DeleteAsync(id, isFinance: true);
Assert.Null(await db.Expenses.FirstOrDefaultAsync(e => e.Id == id));
}
[Fact]
public async Task Receipt_SaveThenOpen_RoundTrips()
{
var (svc, _, _) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
using var input = new MemoryStream(Encoding.UTF8.GetBytes("img"));
await svc.SaveReceiptAsync(id, input, "r.jpg", isFinance: false);
var got = await svc.OpenReceiptAsync(id, isFinance: true);
Assert.NotNull(got);
}
}
@@ -0,0 +1,93 @@
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class GivingCategoryServiceTests
{
private static IHttpContextAccessor BuildAccessor(string userId = "test-user")
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return mock.Object;
}
private static AppDbContext BuildDb(string userId = "test-user")
{
var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId)));
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(interceptor)
.Options);
}
[Fact]
public async Task CreateAsync_ReturnsId_AndDefaultsActive()
{
using var db = BuildDb();
var svc = new GivingCategoryService(db);
var id = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Tithe", Name_zh = "什一" });
var saved = await db.GivingCategories.FindAsync(id);
Assert.NotNull(saved);
Assert.True(saved!.IsActive);
Assert.Equal("Tithe", saved.Name_en);
}
[Fact]
public async Task GetAllAsync_ExcludesInactive_ByDefault()
{
using var db = BuildDb();
var svc = new GivingCategoryService(db);
var id1 = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Active" });
var id2 = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Gone" });
await svc.DeactivateAsync(id2);
var active = await svc.GetAllAsync(includeInactive: false);
var all = await svc.GetAllAsync(includeInactive: true);
Assert.Single(active);
Assert.Equal(2, all.Count);
}
[Fact]
public async Task DeactivateAsync_SetsIsActiveFalse()
{
using var db = BuildDb();
var svc = new GivingCategoryService(db);
var id = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Temp" });
await svc.DeactivateAsync(id);
var saved = await db.GivingCategories.FindAsync(id);
Assert.False(saved!.IsActive);
}
[Fact]
public async Task UpdateAsync_Throws_WhenMissing()
{
using var db = BuildDb();
var svc = new GivingCategoryService(db);
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
svc.UpdateAsync(999, new UpdateGivingCategoryRequest { Name_en = "X" }));
}
[Fact]
public async Task DeactivateAsync_Throws_WhenMissing()
{
using var db = BuildDb();
var svc = new GivingCategoryService(db);
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeactivateAsync(999));
}
}
@@ -0,0 +1,161 @@
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class GivingServiceTests
{
private static IHttpContextAccessor BuildAccessor(string userId = "test-user")
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return mock.Object;
}
private static AppDbContext BuildDb(string userId = "test-user")
{
var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId)));
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(interceptor)
.Options);
}
private static async Task<int> SeedCategoryAsync(AppDbContext db)
{
var c = new GivingCategory { Name_en = "Tithe", IsActive = true };
db.GivingCategories.Add(c);
await db.SaveChangesAsync();
return c.Id;
}
[Fact]
public async Task CreateAsync_PersistsGiving()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new GivingService(db);
var id = await svc.CreateAsync(new CreateGivingRequest
{
GivingCategoryId = catId, Amount = 100m, PaymentMethod = "Cash",
GivingDate = new DateOnly(2026, 5, 31), IsAnonymous = true,
});
var saved = await db.Givings.FindAsync(id);
Assert.NotNull(saved);
Assert.Equal(100m, saved!.Amount);
Assert.Null(saved.OfferingSessionId);
}
[Fact]
public async Task GetPagedAsync_FiltersByCategory()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new GivingService(db);
await svc.CreateAsync(new CreateGivingRequest { GivingCategoryId = catId, Amount = 10m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026,5,31) });
var page = await svc.GetPagedAsync(1, 20, null, catId, null, null);
Assert.Equal(1, page.TotalCount);
Assert.Equal("Tithe", page.Items[0].CategoryName);
}
[Fact]
public async Task UpdateAsync_Throws_WhenGivingBelongsToSubmittedSession()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var session = new OfferingSession { SessionDate = new DateOnly(2026,5,31), Status = "Submitted" };
db.OfferingSessions.Add(session);
await db.SaveChangesAsync();
var giving = new Giving { GivingCategoryId = catId, Amount = 50m, PaymentMethod = "Cash",
GivingDate = new DateOnly(2026,5,31), OfferingSessionId = session.Id };
db.Givings.Add(giving);
await db.SaveChangesAsync();
var svc = new GivingService(db);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.UpdateAsync(giving.Id, new UpdateGivingRequest
{ GivingCategoryId = catId, Amount = 999m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026,5,31) }));
}
[Fact]
public async Task DeleteAsync_Throws_WhenMissing()
{
using var db = BuildDb();
var svc = new GivingService(db);
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeleteAsync(999));
}
[Fact]
public async Task CreateAsync_Anonymous_NullsProvidedMemberId()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new GivingService(db);
var id = await svc.CreateAsync(new CreateGivingRequest
{
GivingCategoryId = catId, Amount = 25m, PaymentMethod = "Cash",
GivingDate = new DateOnly(2026, 5, 31),
IsAnonymous = true, MemberId = 12345, // provided, but must be stripped
});
var saved = await db.Givings.FindAsync(id);
Assert.True(saved!.IsAnonymous);
Assert.Null(saved.MemberId);
}
[Fact]
public async Task DeleteAsync_Throws_WhenGivingBelongsToSubmittedSession()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var session = new OfferingSession { SessionDate = new DateOnly(2026, 5, 31), Status = "Submitted" };
db.OfferingSessions.Add(session);
await db.SaveChangesAsync();
var giving = new Giving { GivingCategoryId = catId, Amount = 50m, PaymentMethod = "Cash",
GivingDate = new DateOnly(2026, 5, 31), OfferingSessionId = session.Id };
db.Givings.Add(giving);
await db.SaveChangesAsync();
var svc = new GivingService(db);
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.DeleteAsync(giving.Id));
}
[Fact]
public async Task GetPagedAsync_MatchesByMemberName()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var member = new Member { FirstName_en = "Grace", LastName_en = "Lee" };
db.Members.Add(member);
await db.SaveChangesAsync();
var svc = new GivingService(db);
await svc.CreateAsync(new CreateGivingRequest
{
GivingCategoryId = catId, Amount = 75m, PaymentMethod = "Cash",
GivingDate = new DateOnly(2026, 5, 31), MemberId = member.Id,
});
var page = await svc.GetPagedAsync(1, 20, "grace", null, null, null);
Assert.Equal(1, page.TotalCount);
Assert.Equal(member.Id, page.Items[0].MemberId);
}
}
@@ -0,0 +1,52 @@
using System.Text;
using Microsoft.Extensions.Configuration;
using ROLAC.API.Services.Storage;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class LocalDiskFileStorageTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "rolac-test-" + Guid.NewGuid());
private LocalDiskFileStorage Build()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Storage:LocalRoot"] = _root })
.Build();
return new LocalDiskFileStorage(config);
}
[Fact]
public async Task SaveThenOpen_RoundTrips()
{
var fs = Build();
using var input = new MemoryStream(Encoding.UTF8.GetBytes("hello"));
var path = await fs.SaveAsync(input, "finance/receipts/2026/5/1-r.txt");
await using var read = await fs.OpenReadAsync(path);
Assert.NotNull(read);
using var sr = new StreamReader(read!);
Assert.Equal("hello", await sr.ReadToEndAsync());
}
[Fact]
public async Task OpenRead_ReturnsNull_WhenMissing()
{
var fs = Build();
Assert.Null(await fs.OpenReadAsync("finance/receipts/none.txt"));
}
[Fact]
public async Task Save_RejectsPathTraversal()
{
var fs = Build();
using var input = new MemoryStream(Encoding.UTF8.GetBytes("x"));
await Assert.ThrowsAsync<ArgumentException>(() => fs.SaveAsync(input, "../escape.txt"));
}
public void Dispose()
{
if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true);
}
}
@@ -0,0 +1,206 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Members;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class MemberServiceTests
{
private static IHttpContextAccessor BuildAccessor(string userId = "test-user")
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return mock.Object;
}
/// <summary>
/// Builds an InMemory AppDbContext that includes the AuditSaveChangesInterceptor
/// so that CreatedBy/UpdatedBy are stamped on save (required by InMemory null checks).
/// </summary>
private static AppDbContext BuildDb(string userId = "test-user")
{
var accessor = BuildAccessor(userId);
var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(accessor));
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(interceptor)
.Options);
}
// ── Create ───────────────────────────────────────────────────────────────
[Fact]
public async Task CreateAsync_ReturnsNewId()
{
using var db = BuildDb();
var svc = new MemberService(db, BuildAccessor());
var request = new CreateMemberRequest { FirstName_en = "Chris", LastName_en = "Chen" };
var id = await svc.CreateAsync(request);
Assert.True(id > 0);
var saved = await db.Members.FindAsync(id);
Assert.NotNull(saved);
Assert.Equal("Chris", saved.FirstName_en);
}
[Fact]
public async Task CreateAsync_SavesNickName()
{
using var db = BuildDb();
var svc = new MemberService(db, BuildAccessor());
var request = new CreateMemberRequest
{ FirstName_en = "Yuan", LastName_en = "Chen", NickName = "Chris" };
var id = await svc.CreateAsync(request);
var saved = await db.Members.FindAsync(id);
Assert.Equal("Chris", saved!.NickName);
}
// ── GetById ──────────────────────────────────────────────────────────────
[Fact]
public async Task GetByIdAsync_ReturnsDto_WhenExists()
{
using var db = BuildDb();
var svc = new MemberService(db, BuildAccessor());
var id = await svc.CreateAsync(
new CreateMemberRequest { FirstName_en = "A", LastName_en = "B" });
var dto = await svc.GetByIdAsync(id);
Assert.NotNull(dto);
Assert.Equal(id, dto.Id);
Assert.Equal("A", dto.FirstName_en);
}
[Fact]
public async Task GetByIdAsync_ReturnsNull_WhenNotFound()
{
using var db = BuildDb();
var svc = new MemberService(db, BuildAccessor());
var dto = await svc.GetByIdAsync(9999);
Assert.Null(dto);
}
// ── GetPaged ─────────────────────────────────────────────────────────────
[Fact]
public async Task GetPagedAsync_FiltersOnSearch()
{
using var db = BuildDb();
var svc = new MemberService(db, BuildAccessor());
await svc.CreateAsync(new CreateMemberRequest { FirstName_en = "Chris", LastName_en = "Chen" });
await svc.CreateAsync(new CreateMemberRequest { FirstName_en = "Alice", LastName_en = "Wang" });
var result = await svc.GetPagedAsync(1, 20, "Chris", null, null);
Assert.Equal(1, result.TotalCount);
Assert.Equal("Chris", result.Items[0].FirstName_en);
}
[Fact]
public async Task GetPagedAsync_FiltersOnStatus()
{
using var db = BuildDb();
var svc = new MemberService(db, BuildAccessor());
await svc.CreateAsync(new CreateMemberRequest
{ FirstName_en = "A", LastName_en = "A", Status = "Member" });
await svc.CreateAsync(new CreateMemberRequest
{ FirstName_en = "B", LastName_en = "B", Status = "Visitor" });
var result = await svc.GetPagedAsync(1, 20, null, "Visitor", null);
Assert.Equal(1, result.TotalCount);
Assert.Equal("Visitor", result.Items[0].Status);
}
[Fact]
public async Task GetPagedAsync_SearchesNickName()
{
using var db = BuildDb();
var svc = new MemberService(db, BuildAccessor());
await svc.CreateAsync(new CreateMemberRequest
{ FirstName_en = "Yuan", LastName_en = "Chen", NickName = "Chris" });
var result = await svc.GetPagedAsync(1, 20, "Chris", null, null);
Assert.Equal(1, result.TotalCount);
}
// ── Update ───────────────────────────────────────────────────────────────
[Fact]
public async Task UpdateAsync_PersistsChanges()
{
using var db = BuildDb();
var svc = new MemberService(db, BuildAccessor());
var id = await svc.CreateAsync(
new CreateMemberRequest { FirstName_en = "Old", LastName_en = "Name" });
await svc.UpdateAsync(id, new UpdateMemberRequest
{ FirstName_en = "New", LastName_en = "Name", Country = "USA",
Status = "Member", LanguagePreference = "en" });
var saved = await db.Members.FindAsync(id);
Assert.Equal("New", saved!.FirstName_en);
}
[Fact]
public async Task UpdateAsync_ThrowsKeyNotFound_WhenMissing()
{
using var db = BuildDb();
var svc = new MemberService(db, BuildAccessor());
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
svc.UpdateAsync(9999, new UpdateMemberRequest
{ FirstName_en = "X", LastName_en = "Y", Country = "USA",
Status = "Member", LanguagePreference = "en" }));
}
// ── Delete (soft) ────────────────────────────────────────────────────────
[Fact]
public async Task DeleteAsync_SoftDeletesMember()
{
using var db = BuildDb();
var svc = new MemberService(db, BuildAccessor("deleter-id"));
var id = await svc.CreateAsync(
new CreateMemberRequest { FirstName_en = "A", LastName_en = "B" });
await svc.DeleteAsync(id);
// Query-filtered view returns null
var filtered = await db.Members.FindAsync(id);
Assert.Null(filtered);
// Raw view shows IsDeleted = true
var raw = await db.Members.IgnoreQueryFilters()
.FirstAsync(m => m.Id == id);
Assert.True(raw.IsDeleted);
Assert.Equal("deleter-id", raw.DeletedBy);
Assert.NotNull(raw.DeletedAt);
}
[Fact]
public async Task DeleteAsync_ThrowsKeyNotFound_WhenMissing()
{
using var db = BuildDb();
var svc = new MemberService(db, BuildAccessor());
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeleteAsync(9999));
}
}
@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class MinistryServiceTests
{
private static AppDbContext BuildDb()
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
}
[Fact]
public async Task GetAllAsync_OrdersBySortOrder_AndExcludesInactive()
{
using var db = BuildDb();
db.Ministries.AddRange(
new Ministry { Name_en = "B", SortOrder = 2, IsActive = true },
new Ministry { Name_en = "A", SortOrder = 1, IsActive = true },
new Ministry { Name_en = "Z", SortOrder = 3, IsActive = false });
await db.SaveChangesAsync();
var svc = new MinistryService(db);
var active = await svc.GetAllAsync(includeInactive: false);
var all = await svc.GetAllAsync(includeInactive: true);
Assert.Equal(2, active.Count);
Assert.Equal("A", active[0].Name_en);
Assert.Equal(3, all.Count);
}
}
@@ -0,0 +1,80 @@
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class MonthlyStatementServiceTests
{
private static AppDbContext BuildDb()
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
}
private static MonthlyStatementService Build(AppDbContext db)
{
var mock = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) };
mock.Setup(x => x.HttpContext).Returns(ctx);
return new MonthlyStatementService(db, mock.Object, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
}
[Fact]
public async Task Create_ComputesGivingAndPaidExpenses_ForMonthOnly()
{
using var db = BuildDb();
db.GivingCategories.Add(new GivingCategory { Id = 1, Name_en = "Tithe" });
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Admin" });
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Other" });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Misc" });
db.Givings.Add(new Giving { GivingCategoryId = 1, Amount = 1000m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026, 5, 10) });
db.Givings.Add(new Giving { GivingCategoryId = 1, Amount = 500m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026, 6, 1) });
db.Expenses.Add(new Expense { MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Type = "VendorPayment", Status = "Paid", Amount = 300m, Description = "x", ExpenseDate = new DateOnly(2026, 5, 20) });
db.Expenses.Add(new Expense { MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Type = "StaffReimbursement", Status = "Approved", Amount = 999m, Description = "not paid", ExpenseDate = new DateOnly(2026, 5, 21) });
await db.SaveChangesAsync();
var svc = Build(db);
var id = await svc.CreateAsync(new CreateMonthlyStatementRequest
{ Year = 2026, Month = 5, OpeningBalance = 2000m, TotalOtherIncome = 100m, BankStatementBalance = 2800m });
var dto = await svc.GetByIdAsync(id);
Assert.Equal(1000m, dto!.TotalGiving);
Assert.Equal(300m, dto.TotalExpenses);
Assert.Equal(2800m, dto.CalculatedClosingBalance);
Assert.Equal(0m, dto.Difference);
}
[Fact]
public async Task Create_Duplicate_Throws()
{
using var db = BuildDb();
var svc = Build(db);
await svc.CreateAsync(new CreateMonthlyStatementRequest { Year = 2026, Month = 5 });
await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.CreateAsync(new CreateMonthlyStatementRequest { Year = 2026, Month = 5 }));
}
[Fact]
public async Task Update_AfterFinalize_Throws()
{
using var db = BuildDb();
var svc = Build(db);
var id = await svc.CreateAsync(new CreateMonthlyStatementRequest { Year = 2026, Month = 5 });
await svc.FinalizeAsync(id);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.UpdateAsync(id, new UpdateMonthlyStatementRequest { OpeningBalance = 1m }));
}
}
@@ -0,0 +1,112 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Notifications;
using Xunit;
namespace ROLAC.API.Tests.Services.Notifications;
public class EmailServiceTests
{
// Records every email it is asked to send; can be told to throw for a given address.
private sealed class FakeSmtpDispatcher : ISmtpDispatcher
{
public List<OutboundEmail> Sent { get; } = new();
public string? FailForAddress { get; set; }
public Task SendAsync(OutboundEmail email, CancellationToken ct = default)
{
if (email.ToAddress == FailForAddress)
throw new InvalidOperationException("smtp rejected");
Sent.Add(email);
return Task.CompletedTask;
}
}
private static CurrentUserAccessor BuildAccessor(string userId = "test-user")
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new CurrentUserAccessor(mock.Object);
}
private static AppDbContext BuildDb()
{
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor());
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(interceptor)
.Options);
}
private static async Task<int> SeedMemberAsync(AppDbContext db, string? email)
{
var member = new Member { FirstName_en = "Test", LastName_en = "User", Email = email };
db.Members.Add(member);
await db.SaveChangesAsync();
return member.Id;
}
[Fact]
public async Task SendAsync_ResolvesMemberEmails_MergesRawAddresses_AndDedupes()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db, "member@example.com");
var dispatcher = new FakeSmtpDispatcher();
var service = new EmailService(db, dispatcher, BuildAccessor());
var message = new EmailMessage(
MemberIds: new[] { memberId },
Addresses: new[] { "extra@example.com", "member@example.com" }, // dup of member email
Subject: "Hi", HtmlBody: "<p>Body</p>");
var result = await service.SendAsync(message);
Assert.Equal(2, result.SentCount); // member@ + extra@, dup dropped
Assert.Equal(0, result.FailedCount);
Assert.Equal(2, dispatcher.Sent.Count);
Assert.Equal(2, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Sent));
}
[Fact]
public async Task SendAsync_SkipsMembersWithNoEmail()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db, null);
var dispatcher = new FakeSmtpDispatcher();
var service = new EmailService(db, dispatcher, BuildAccessor());
var result = await service.SendAsync(new EmailMessage(
new[] { memberId }, Array.Empty<string>(), "Hi", "<p>Body</p>"));
Assert.Equal(0, result.SentCount);
Assert.Empty(dispatcher.Sent);
}
[Fact]
public async Task SendAsync_LogsFailure_WithoutAbortingBatch()
{
using var db = BuildDb();
var dispatcher = new FakeSmtpDispatcher { FailForAddress = "bad@example.com" };
var service = new EmailService(db, dispatcher, BuildAccessor());
var result = await service.SendAsync(new EmailMessage(
Array.Empty<int>(),
new[] { "bad@example.com", "good@example.com" },
"Hi", "<p>Body</p>"));
Assert.Equal(1, result.SentCount);
Assert.Equal(1, result.FailedCount);
Assert.Single(result.Failures);
Assert.Equal("bad@example.com", result.Failures[0].Target);
Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed));
}
}
@@ -0,0 +1,77 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Options;
using ROLAC.API.Services.Notifications;
using Xunit;
namespace ROLAC.API.Tests.Services.Notifications;
public class LineMessageChannelTests
{
// Captures the outgoing request and returns a canned response.
private sealed class CapturingHandler : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
public string? LastBody { get; private set; }
public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
public string ResponseBody { get; set; } = "{}";
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
LastBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(StatusCode) { Content = new StringContent(ResponseBody) };
}
}
private static LineMessageChannel BuildChannel(CapturingHandler handler)
{
var http = new HttpClient(handler);
var options = Options.Create(new LineOptions { ChannelAccessToken = "tok", ChannelSecret = "sec" });
return new LineMessageChannel(http, options);
}
[Fact]
public async Task PushToUserAsync_PostsTextMessage_WithBearerToken()
{
var handler = new CapturingHandler();
var channel = BuildChannel(handler);
var result = await channel.PushToUserAsync("U123", "hello");
Assert.True(result.Success);
Assert.Equal("https://api.line.me/v2/bot/message/push", handler.LastRequest!.RequestUri!.ToString());
Assert.Equal("Bearer", handler.LastRequest.Headers.Authorization!.Scheme);
Assert.Equal("tok", handler.LastRequest.Headers.Authorization.Parameter);
using var doc = JsonDocument.Parse(handler.LastBody!);
Assert.Equal("U123", doc.RootElement.GetProperty("to").GetString());
Assert.Equal("hello", doc.RootElement.GetProperty("messages")[0].GetProperty("text").GetString());
}
[Fact]
public async Task ReplyAsync_PostsToReplyEndpoint_WithReplyToken()
{
var handler = new CapturingHandler();
var channel = BuildChannel(handler);
await channel.ReplyAsync("RTOKEN", "hi back");
Assert.Equal("https://api.line.me/v2/bot/message/reply", handler.LastRequest!.RequestUri!.ToString());
using var doc = JsonDocument.Parse(handler.LastBody!);
Assert.Equal("RTOKEN", doc.RootElement.GetProperty("replyToken").GetString());
}
[Fact]
public async Task PushToUserAsync_ReturnsFailure_OnNonSuccessStatus()
{
var handler = new CapturingHandler { StatusCode = HttpStatusCode.TooManyRequests, ResponseBody = "quota" };
var channel = BuildChannel(handler);
var result = await channel.PushToUserAsync("U123", "hello");
Assert.False(result.Success);
Assert.Contains("429", result.Error);
}
}
@@ -0,0 +1,211 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using ROLAC.API.Entities.Notifications;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Notifications;
using Xunit;
namespace ROLAC.API.Tests.Services.Notifications;
public class LineNotificationServiceTests
{
// Records pushes; can be told to fail every call.
private sealed class FakeMessageChannel : IMessageChannel
{
public List<(string Target, string Text)> UserPushes { get; } = new();
public List<(string Target, string Text)> GroupPushes { get; } = new();
public bool Fail { get; set; }
public Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default)
{
UserPushes.Add((externalId, text));
return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null));
}
public Task<MessageSendResult> PushToGroupAsync(string externalId, string text, CancellationToken ct = default)
{
GroupPushes.Add((externalId, text));
return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null));
}
public Task<MessageSendResult> ReplyAsync(string replyToken, string text, CancellationToken ct = default)
=> Task.FromResult(new MessageSendResult(true, null));
}
private static CurrentUserAccessor BuildAccessor(string userId = "test-user")
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new CurrentUserAccessor(mock.Object);
}
private static AppDbContext BuildDb()
{
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor());
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(interceptor)
.Options);
}
private static async Task<int> SeedMemberAsync(AppDbContext db)
{
var member = new Member { FirstName_en = "Test", LastName_en = "User" };
db.Members.Add(member);
await db.SaveChangesAsync();
return member.Id;
}
[Fact]
public async Task GenerateLineBindingCodeAsync_PersistsUnconsumedCode()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
var service = new LineNotificationService(db, new FakeMessageChannel());
var code = await service.GenerateLineBindingCodeAsync(memberId);
var stored = await db.LineBindingCodes.SingleAsync();
Assert.Equal(code, stored.Code);
Assert.Null(stored.ConsumedAt);
Assert.True(stored.ExpiresAt > DateTime.UtcNow);
}
[Fact]
public async Task TryBindMemberAsync_BindsMember_AndConsumesCode()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
var service = new LineNotificationService(db, new FakeMessageChannel());
var code = await service.GenerateLineBindingCodeAsync(memberId);
var result = await service.TryBindMemberAsync("U999", code);
Assert.True(result.Success);
Assert.Equal(memberId, result.MemberId);
var binding = await db.MemberChannelBindings.SingleAsync();
Assert.Equal("U999", binding.ExternalId);
Assert.NotNull((await db.LineBindingCodes.SingleAsync()).ConsumedAt);
}
[Fact]
public async Task TryBindMemberAsync_Fails_ForExpiredOrUsedOrUnknownCode()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
db.LineBindingCodes.Add(new LineBindingCode
{
Code = "EXPIRE", MemberId = memberId, ExpiresAt = DateTime.UtcNow.AddMinutes(-1),
});
await db.SaveChangesAsync();
var service = new LineNotificationService(db, new FakeMessageChannel());
Assert.False((await service.TryBindMemberAsync("U1", "EXPIRE")).Success); // expired
Assert.False((await service.TryBindMemberAsync("U1", "NOPE")).Success); // unknown
Assert.Empty(await db.MemberChannelBindings.ToListAsync());
}
[Fact]
public async Task TryBindMemberAsync_Rebinds_UpdatesExistingBinding()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
var service = new LineNotificationService(db, new FakeMessageChannel());
await service.TryBindMemberAsync("U-OLD", await service.GenerateLineBindingCodeAsync(memberId));
await service.TryBindMemberAsync("U-NEW", await service.GenerateLineBindingCodeAsync(memberId));
var binding = await db.MemberChannelBindings.SingleAsync();
Assert.Equal("U-NEW", binding.ExternalId);
}
[Fact]
public async Task RegisterGroupAsync_IsIdempotent_AndDeactivateFlips()
{
using var db = BuildDb();
var service = new LineNotificationService(db, new FakeMessageChannel());
await service.RegisterGroupAsync("G1");
await service.RegisterGroupAsync("G1"); // second call must not duplicate
Assert.Equal(1, await db.MessagingGroups.CountAsync());
Assert.True((await db.MessagingGroups.SingleAsync()).IsActive);
await service.DeactivateGroupAsync("G1");
Assert.False((await db.MessagingGroups.SingleAsync()).IsActive);
}
[Fact]
public async Task SendLineAsync_PushesToBoundMembersAndActiveGroups_AndLogs()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
db.MemberChannelBindings.Add(new MemberChannelBinding
{
MemberId = memberId, Channel = "line", ExternalId = "U-MEM", BoundAt = DateTime.UtcNow,
});
var activeGroup = new MessagingGroup { Channel = "line", ExternalId = "G-ON", IsActive = true, RegisteredAt = DateTime.UtcNow };
var deadGroup = new MessagingGroup { Channel = "line", ExternalId = "G-OFF", IsActive = false, RegisteredAt = DateTime.UtcNow };
db.MessagingGroups.AddRange(activeGroup, deadGroup);
await db.SaveChangesAsync();
var channel = new FakeMessageChannel();
var service = new LineNotificationService(db, channel);
var result = await service.SendLineAsync("notice", new[] { memberId },
new[] { activeGroup.Id, deadGroup.Id }, "admin-1");
Assert.Equal(2, result.SentCount); // member + active group only
Assert.Single(channel.UserPushes);
Assert.Single(channel.GroupPushes); // inactive group skipped
Assert.Equal(2, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Sent));
}
[Fact]
public async Task SendLineAsync_RecordsFailures_WhenChannelFails()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
db.MemberChannelBindings.Add(new MemberChannelBinding
{
MemberId = memberId, Channel = "line", ExternalId = "U-MEM", BoundAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
var service = new LineNotificationService(db, new FakeMessageChannel { Fail = true });
var result = await service.SendLineAsync("notice", new[] { memberId }, Array.Empty<int>(), "admin-1");
Assert.Equal(0, result.SentCount);
Assert.Equal(1, result.FailedCount);
Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed));
}
[Fact]
public async Task SendLineAsync_SkipsSoftDeletedMembers()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
db.MemberChannelBindings.Add(new MemberChannelBinding
{
MemberId = memberId, Channel = "line", ExternalId = "U-DEL", BoundAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
// Soft-delete the member.
var member = await db.Members.FirstAsync(m => m.Id == memberId);
member.IsDeleted = true;
await db.SaveChangesAsync();
var channel = new FakeMessageChannel();
var service = new LineNotificationService(db, channel);
var result = await service.SendLineAsync("notice", new[] { memberId }, Array.Empty<int>(), "admin-1");
Assert.Equal(0, result.SentCount);
Assert.Empty(channel.UserPushes);
}
}
@@ -0,0 +1,47 @@
using System.Security.Cryptography;
using System.Text;
using ROLAC.API.Services.Notifications;
using Xunit;
namespace ROLAC.API.Tests.Services.Notifications;
public class LineSignatureTests
{
private const string Secret = "test-channel-secret";
private static string Sign(string body)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(Secret));
return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(body)));
}
[Fact]
public void IsValid_ReturnsTrue_ForMatchingSignature()
{
var body = """{"events":[]}""";
var signature = Sign(body);
var result = LineSignature.IsValid(Secret, Encoding.UTF8.GetBytes(body), signature);
Assert.True(result);
}
[Fact]
public void IsValid_ReturnsFalse_ForTamperedBody()
{
var signature = Sign("""{"events":[]}""");
var result = LineSignature.IsValid(Secret, Encoding.UTF8.GetBytes("""{"events":[1]}"""), signature);
Assert.False(result);
}
[Fact]
public void IsValid_ReturnsFalse_ForNullOrEmptyHeader()
{
var body = Encoding.UTF8.GetBytes("""{"events":[]}""");
Assert.False(LineSignature.IsValid(Secret, body, null));
Assert.False(LineSignature.IsValid(Secret, body, ""));
}
}
@@ -0,0 +1,167 @@
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using ROLAC.API.Services.Storage;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class OfferingSessionServiceTests
{
// Proof storage is not exercised by these tests; a no-op keeps the service constructible.
private sealed class NoOpFileStorage : IFileStorage
{
public Task<string> SaveAsync(Stream content, string relativePath, CancellationToken ct = default)
=> Task.FromResult(relativePath);
public Task<Stream?> OpenReadAsync(string relativePath, CancellationToken ct = default)
=> Task.FromResult<Stream?>(null);
public Task DeleteAsync(string relativePath, CancellationToken ct = default)
=> Task.CompletedTask;
}
private static IHttpContextAccessor BuildAccessor(string userId = "test-user")
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return mock.Object;
}
private static AppDbContext BuildDb(string userId = "test-user")
{
var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId)));
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(interceptor)
.Options);
}
private static async Task<int> SeedCategoryAsync(AppDbContext db)
{
var c = new GivingCategory { Name_en = "Tithe", IsActive = true };
db.GivingCategories.Add(c);
await db.SaveChangesAsync();
return c.Id;
}
private static CreateOfferingSessionRequest BuildRequest(int catId, DateOnly date) => new()
{
SessionDate = date, CashTotal = 150m, CheckTotal = 0m,
Givings =
[
new() { GivingCategoryId = catId, Amount = 100m, PaymentMethod = "Cash" },
new() { GivingCategoryId = catId, Amount = 50m, PaymentMethod = "Cash", IsAnonymous = true },
],
};
[Fact]
public async Task CreateAsync_RecomputesSystemTotalAndDifference_ServerSide()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
var saved = await db.OfferingSessions.FindAsync(id);
Assert.Equal("Submitted", saved!.Status);
Assert.Equal(150m, saved.SystemTotal);
Assert.Equal(0m, saved.Difference);
Assert.NotNull(saved.SubmittedAt);
Assert.Equal(2, await db.Givings.CountAsync(g => g.OfferingSessionId == id));
}
[Fact]
public async Task CreateAsync_LinesGetSessionDateAndAnonymousNullsMember()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
var lines = await db.Givings.Where(g => g.OfferingSessionId == id).ToListAsync();
Assert.All(lines, l => Assert.Equal(new DateOnly(2026,5,31), l.GivingDate));
Assert.Contains(lines, l => l.IsAnonymous && l.MemberId == null);
}
[Fact]
public async Task CreateAsync_Throws_OnDuplicateSessionDate()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))));
}
[Fact]
public async Task ReplaceAsync_Throws_WhenSessionIsSubmitted()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.ReplaceAsync(id, BuildRequest(catId, new DateOnly(2026, 5, 31))));
}
[Fact]
public async Task ReopenThenReplace_SwapsLinesAndResubmits()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
await svc.ReopenAsync(id);
var reopened = await db.OfferingSessions.FindAsync(id);
Assert.Equal("Draft", reopened!.Status);
var newReq = new CreateOfferingSessionRequest
{
SessionDate = new DateOnly(2026,5,31), CashTotal = 200m, CheckTotal = 0m,
Givings = [ new() { GivingCategoryId = catId, Amount = 200m, PaymentMethod = "Cash" } ],
};
await svc.ReplaceAsync(id, newReq);
var after = await db.OfferingSessions.FindAsync(id);
Assert.Equal("Submitted", after!.Status);
Assert.Equal(200m, after.SystemTotal);
Assert.Equal(1, await db.Givings.CountAsync(g => g.OfferingSessionId == id));
}
[Fact]
public async Task GetByIdAsync_ReturnsCheckZelleAndPayPalRefs()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
var req = new CreateOfferingSessionRequest
{
SessionDate = new DateOnly(2026, 6, 7), CashTotal = 0m, CheckTotal = 100m,
Givings = [ new() { GivingCategoryId = catId, Amount = 100m, PaymentMethod = "Zelle",
ZelleReferenceCode = "Z-123", PayPalTransactionId = "PP-456", CheckNumber = "C-789" } ],
};
var id = await svc.CreateAsync(req);
var dto = await svc.GetByIdAsync(id);
Assert.NotNull(dto);
var line = Assert.Single(dto!.Givings);
Assert.Equal("Z-123", line.ZelleReferenceCode);
Assert.Equal("PP-456", line.PayPalTransactionId);
Assert.Equal("C-789", line.CheckNumber);
}
}
@@ -0,0 +1,185 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using ROLAC.API.Authorization;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Permissions;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class PermissionServiceTests
{
// -----------------------------------------------------------------------
// Harness: a real PermissionService backed by an in-memory EF database.
// -----------------------------------------------------------------------
private sealed class Harness
{
public required ServiceProvider Provider { get; init; }
public required PermissionService Service { get; init; }
public async Task SeedRoleAsync(string roleName, params RolePermission[] permissions)
{
using var scope = Provider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var role = new AppRole { Id = $"role-{roleName}", Name = roleName, NormalizedName = roleName.ToUpperInvariant() };
db.Roles.Add(role);
foreach (var permission in permissions)
{
permission.RoleId = role.Id;
db.RolePermissions.Add(permission);
}
await db.SaveChangesAsync();
}
}
private static Harness BuildHarness()
{
var dbName = Guid.NewGuid().ToString();
var services = new ServiceCollection();
services.AddDbContext<AppDbContext>(opt => opt.UseInMemoryDatabase(dbName));
var provider = services.BuildServiceProvider();
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
var cache = new MemoryCache(new MemoryCacheOptions());
return new Harness
{
Provider = provider,
Service = new PermissionService(scopeFactory, cache,
new ROLAC.API.Services.Logging.SystemLogQueue(),
new Microsoft.AspNetCore.Http.HttpContextAccessor()),
};
}
private static RolePermission Perm(string module, bool r = false, bool w = false, bool d = false, bool a = false)
=> new() { Module = module, CanRead = r, CanWrite = w, CanDelete = d, CanApprove = a };
// -----------------------------------------------------------------------
// HasPermissionAsync
// -----------------------------------------------------------------------
[Fact]
public async Task HasPermission_RoleGrantsAction_ReturnsTrue()
{
var h = BuildHarness();
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true, w: true));
Assert.True(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Read));
Assert.True(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Write));
}
[Fact]
public async Task HasPermission_RoleLacksAction_ReturnsFalse()
{
var h = BuildHarness();
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true)); // read only
Assert.False(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Delete));
Assert.False(await h.Service.HasPermissionAsync(["finance"], Modules.Members, PermissionActions.Read));
}
[Fact]
public async Task HasPermission_UnionAcrossRoles_ReturnsTrueIfAnyRoleGrants()
{
var h = BuildHarness();
await h.SeedRoleAsync("pastor", Perm(Modules.Members, r: true));
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true, w: true));
// User holds both roles — should get the union.
Assert.True(await h.Service.HasPermissionAsync(["pastor", "finance"], Modules.Members, PermissionActions.Read));
Assert.True(await h.Service.HasPermissionAsync(["pastor", "finance"], Modules.Givings, PermissionActions.Write));
Assert.False(await h.Service.HasPermissionAsync(["pastor", "finance"], Modules.Members, PermissionActions.Delete));
}
// -----------------------------------------------------------------------
// GetEffectivePermissionsAsync
// -----------------------------------------------------------------------
[Fact]
public async Task GetEffectivePermissions_SuperAdmin_ReturnsAllModulesFull()
{
var h = BuildHarness(); // no rows seeded at all
var effective = await h.Service.GetEffectivePermissionsAsync(["super_admin"]);
Assert.Equal(Modules.All.Count, effective.Count);
foreach (var module in Modules.All)
{
Assert.True(effective[module].Read);
Assert.True(effective[module].Write);
Assert.True(effective[module].Delete);
Assert.True(effective[module].Approve);
}
}
[Fact]
public async Task GetEffectivePermissions_MergesFlagsAcrossRoles()
{
var h = BuildHarness();
await h.SeedRoleAsync("a", Perm(Modules.Expenses, r: true));
await h.SeedRoleAsync("b", Perm(Modules.Expenses, w: true, a: true));
var effective = await h.Service.GetEffectivePermissionsAsync(["a", "b"]);
Assert.True(effective[Modules.Expenses].Read);
Assert.True(effective[Modules.Expenses].Write);
Assert.True(effective[Modules.Expenses].Approve);
Assert.False(effective[Modules.Expenses].Delete);
}
[Fact]
public async Task GetEffectivePermissions_OmitsModulesWithNoGrant()
{
var h = BuildHarness();
await h.SeedRoleAsync("member"); // role exists but no grants
var effective = await h.Service.GetEffectivePermissionsAsync(["member"]);
Assert.Empty(effective);
}
// -----------------------------------------------------------------------
// Caching / invalidation via UpsertRoleAsync
// -----------------------------------------------------------------------
[Fact]
public async Task UpsertRole_InvalidatesCache_SoNextCheckReflectsNewState()
{
var h = BuildHarness();
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true)); // read only
// Prime the cache with the original snapshot.
Assert.False(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Write));
// Grant write; UpsertRoleAsync must invalidate the cache.
await h.Service.UpsertRoleAsync("finance", [new ModulePermissionDto
{
Module = Modules.Givings, CanRead = true, CanWrite = true,
}]);
Assert.True(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Write));
}
[Fact]
public async Task UpsertRole_SuperAdmin_Throws()
{
var h = BuildHarness();
await h.SeedRoleAsync("super_admin");
await Assert.ThrowsAsync<InvalidOperationException>(
() => h.Service.UpsertRoleAsync("super_admin", [new ModulePermissionDto { Module = Modules.Members, CanRead = true }]));
}
[Fact]
public async Task UpsertRole_UnknownRole_Throws()
{
var h = BuildHarness();
await Assert.ThrowsAsync<KeyNotFoundException>(
() => h.Service.UpsertRoleAsync("ghost", [new ModulePermissionDto { Module = Modules.Members, CanRead = true }]));
}
}
@@ -0,0 +1,154 @@
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class TokenServiceTests
{
private readonly TokenService _sut;
public TokenServiceTests()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "Jwt:SecretKey", "test-secret-key-that-is-at-least-32-characters-long!!" },
{ "Jwt:Issuer", "rolac-api-test" },
{ "Jwt:Audience", "rolac-client-test" },
{ "Jwt:AccessTokenExpiryMinutes", "15" },
{ "Jwt:RefreshTokenExpiryDays", "30" },
})
.Build();
_sut = new TokenService(config);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// <summary>
/// Base64Url-decodes the payload segment of a JWT and returns it as a
/// <see cref="JsonDocument"/>. Caller is responsible for disposing.
/// </summary>
private static JsonDocument ReadJwtPayload(string token)
{
var b64 = token.Split('.')[1];
var mod4 = b64.Length % 4;
if (mod4 > 0) b64 += new string('=', 4 - mod4);
var bytes = Convert.FromBase64String(b64.Replace('-', '+').Replace('_', '/'));
return JsonDocument.Parse(bytes);
}
// ---------------------------------------------------------------------------
// GenerateAccessToken
// ---------------------------------------------------------------------------
[Fact]
public void GenerateAccessToken_ReturnsValidJwt_WithUserIdAndRoles()
{
var user = new AppUser { Id = "user-123", Email = "test@rolac.org", UserName = "test@rolac.org" };
var roles = new List<string> { "member", "finance" };
var token = _sut.GenerateAccessToken(user, roles);
Assert.NotEmpty(token);
// Decode payload without touching any Microsoft.IdentityModel API so that
// library version mismatches (7.1.2 vs 7.5.2) cannot affect this test.
using var doc = ReadJwtPayload(token);
var root = doc.RootElement;
Assert.Equal("user-123", root.GetProperty("sub").GetString());
Assert.Equal("test@rolac.org", root.GetProperty("email").GetString());
// JwtSecurityTokenHandler's DefaultOutboundClaimTypeMap maps
// ClaimTypes.Role → "role", so roles land under the "role" key.
// When multiple claims share a type they are serialised as a JSON array.
var roleEl = root.GetProperty("role");
var roleClaims = roleEl.ValueKind == JsonValueKind.Array
? roleEl.EnumerateArray().Select(e => e.GetString()!).ToList()
: new List<string> { roleEl.GetString()! };
Assert.Contains("member", roleClaims);
Assert.Contains("finance", roleClaims);
}
[Fact]
public void GenerateAccessToken_ExpiresIn15Minutes()
{
var user = new AppUser { Id = "user-123", Email = "test@rolac.org", UserName = "test@rolac.org" };
var token = _sut.GenerateAccessToken(user, new List<string>());
using var doc = ReadJwtPayload(token);
var expUnix = doc.RootElement.GetProperty("exp").GetInt64();
var expectedUnix = DateTimeOffset.UtcNow.AddMinutes(15).ToUnixTimeSeconds();
// Allow ±2 s for test execution time.
Assert.InRange(expUnix, expectedUnix - 2, expectedUnix + 2);
}
// ---------------------------------------------------------------------------
// GenerateRefreshToken
// ---------------------------------------------------------------------------
[Fact]
public void GenerateRefreshToken_Returns86CharUrlSafeBase64()
{
var token = _sut.GenerateRefreshToken();
Assert.NotEmpty(token);
// 64 bytes → 88 standard Base64 chars → 86 URL-safe chars (no '==' padding,
// '+' → '-', '/' → '_'). All chars must be unreserved in RFC 6265 cookie-values.
Assert.Equal(86, token.Length);
Assert.True(
token.All(c => char.IsLetterOrDigit(c) || c == '-' || c == '_'),
"Token must use URL-safe Base64 alphabet (A-Z a-z 0-9 - _)");
}
[Fact]
public void GenerateRefreshToken_ProducesUniqueTokensEachCall()
{
var token1 = _sut.GenerateRefreshToken();
var token2 = _sut.GenerateRefreshToken();
Assert.NotEqual(token1, token2);
}
// ---------------------------------------------------------------------------
// HashToken
// ---------------------------------------------------------------------------
[Fact]
public void HashToken_SameInputProducesSameHash()
{
const string raw = "some-raw-token-value";
var hash1 = _sut.HashToken(raw);
var hash2 = _sut.HashToken(raw);
Assert.Equal(hash1, hash2);
}
[Fact]
public void HashToken_DifferentInputsProduceDifferentHashes()
{
var hash1 = _sut.HashToken("token-a");
var hash2 = _sut.HashToken("token-b");
Assert.NotEqual(hash1, hash2);
}
[Fact]
public void HashToken_Returns64CharLowercaseHexString()
{
var hash = _sut.HashToken("any-token");
Assert.Equal(64, hash.Length);
Assert.True(hash.All(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')));
}
}
@@ -0,0 +1,182 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Users;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class UserManagementServiceTests
{
private static AppDbContext BuildDb() =>
new(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
private static Mock<UserManager<AppUser>> BuildUserManager(
AppUser? findResult = null,
bool createOk = true,
IList<string>? roles = null)
{
var store = new Mock<IUserStore<AppUser>>();
#pragma warning disable CS8625
var mgr = new Mock<UserManager<AppUser>>(
store.Object, null, null, null, null, null, null, null, null);
#pragma warning restore CS8625
mgr.Setup(m => m.FindByIdAsync(It.IsAny<string>()))
.ReturnsAsync(findResult);
mgr.Setup(m => m.FindByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((AppUser?)null);
mgr.Setup(m => m.CreateAsync(It.IsAny<AppUser>(), It.IsAny<string>()))
.ReturnsAsync(createOk ? IdentityResult.Success
: IdentityResult.Failed(new IdentityError { Description = "fail" }));
mgr.Setup(m => m.AddToRolesAsync(It.IsAny<AppUser>(), It.IsAny<IEnumerable<string>>()))
.ReturnsAsync(IdentityResult.Success);
mgr.Setup(m => m.GetRolesAsync(It.IsAny<AppUser>()))
.ReturnsAsync(roles ?? new List<string> { "member" });
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
.ReturnsAsync(IdentityResult.Success);
mgr.Setup(m => m.RemoveFromRolesAsync(It.IsAny<AppUser>(), It.IsAny<IEnumerable<string>>()))
.ReturnsAsync(IdentityResult.Success);
mgr.Setup(m => m.GeneratePasswordResetTokenAsync(It.IsAny<AppUser>()))
.ReturnsAsync("reset-token");
mgr.Setup(m => m.ResetPasswordAsync(It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(IdentityResult.Success);
return mgr;
}
// ── CreateAsync ──────────────────────────────────────────────────────────
[Fact]
public async Task CreateAsync_ReturnsTempPassword()
{
using var db = BuildDb();
// Seed a Member so MemberId validation passes
// Note: InMemory DB requires audit fields — we set them directly
var member = new Member
{
FirstName_en = "A", LastName_en = "B",
CreatedBy = "system", UpdatedBy = "system",
CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow,
};
db.Members.Add(member);
await db.SaveChangesAsync();
var mgr = BuildUserManager();
// Capture the AppUser passed to CreateAsync
AppUser? created = null;
mgr.Setup(m => m.CreateAsync(It.IsAny<AppUser>(), It.IsAny<string>()))
.Callback<AppUser, string>((u, _) => { created = u; u.Id = Guid.NewGuid().ToString(); })
.ReturnsAsync(IdentityResult.Success);
// Mock Users queryable to return empty (no existing user for this member)
mgr.Setup(m => m.Users)
.Returns(new List<AppUser>().AsQueryable());
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
var result = await svc.CreateAsync(new CreateUserRequest
{
MemberId = member.Id,
Email = "test@rolac.org",
Roles = ["member"],
});
Assert.False(string.IsNullOrEmpty(result.TempPassword));
Assert.Equal(12, result.TempPassword.Length);
Assert.NotNull(created);
Assert.Equal(member.Id, created!.MemberId);
}
[Fact]
public async Task CreateAsync_Throws_WhenMemberNotFound()
{
using var db = BuildDb();
var mgr = BuildUserManager();
mgr.Setup(m => m.Users)
.Returns(new List<AppUser>().AsQueryable());
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.CreateAsync(new CreateUserRequest
{ MemberId = 9999, Email = "x@y.com", Roles = ["member"] }));
}
[Fact]
public async Task CreateAsync_Throws_WhenMemberAlreadyHasUser()
{
using var db = BuildDb();
var member = new Member
{
FirstName_en = "A", LastName_en = "B",
CreatedBy = "system", UpdatedBy = "system",
CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow,
};
db.Members.Add(member);
await db.SaveChangesAsync();
var existingUser = new AppUser
{
Id = Guid.NewGuid().ToString(),
UserName = "existing@test.com",
Email = "existing@test.com",
MemberId = member.Id,
};
db.Users.Add(existingUser);
await db.SaveChangesAsync();
var mgr = BuildUserManager();
// The service checks _userManager.Users — we need to return the existing user
mgr.Setup(m => m.Users)
.Returns(new List<AppUser> { existingUser }.AsQueryable());
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.CreateAsync(new CreateUserRequest
{ MemberId = member.Id, Email = "new@test.com", Roles = ["member"] }));
}
// ── DeactivateAsync ──────────────────────────────────────────────────────
[Fact]
public async Task DeactivateAsync_SetsIsActiveFalse()
{
using var db = BuildDb();
var user = new AppUser
{ Id = "u1", UserName = "a@b.com", Email = "a@b.com", IsActive = true };
var mgr = BuildUserManager(findResult: user);
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
await svc.DeactivateAsync("u1");
Assert.False(user.IsActive);
Assert.Equal(DateTimeOffset.MaxValue, user.LockoutEnd);
}
[Fact]
public async Task DeactivateAsync_ThrowsKeyNotFound_WhenUserMissing()
{
using var db = BuildDb();
var mgr = BuildUserManager(findResult: null);
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeactivateAsync("missing"));
}
// ── ResetPasswordAsync ───────────────────────────────────────────────────
[Fact]
public async Task ResetPasswordAsync_ReturnsNewTempPassword()
{
using var db = BuildDb();
var user = new AppUser { Id = "u1", UserName = "a@b.com", Email = "a@b.com" };
var mgr = BuildUserManager(findResult: user);
var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
var pwd = await svc.ResetPasswordAsync("u1");
Assert.Equal(12, pwd.Length);
}
}
@@ -0,0 +1,19 @@
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Tests.TestSupport;
/// <summary>No-op <see cref="IAuditLogger"/> for unit tests that don't assert on audit output.</summary>
public sealed class NullAuditLogger : IAuditLogger
{
public static readonly NullAuditLogger Instance = new();
public void Write(
string action, string category, LogLevelEnum level = LogLevelEnum.Information,
string? entityName = null, string? entityId = null, string? summary = null,
object? before = null, object? after = null,
string? userId = null, string? userEmail = null, string? ipAddress = null)
{
// intentionally empty
}
}
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Authorization;
namespace ROLAC.API.Authorization;
/// <summary>
/// Gates an action/controller on a configurable permission. Usage:
/// <c>[HasPermission(Modules.Members, PermissionActions.Write)]</c>.
/// Encodes the policy name <c>PERM:&lt;module&gt;:&lt;action&gt;</c>, which
/// <see cref="PermissionPolicyProvider"/> turns into a <see cref="PermissionRequirement"/>.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public class HasPermissionAttribute : AuthorizeAttribute
{
public const string PolicyPrefix = "PERM:";
public HasPermissionAttribute(string module, string action)
=> Policy = $"{PolicyPrefix}{module}:{action}";
/// <summary>Parses a policy name back into (module, action), or null if not a PERM policy.</summary>
public static (string Module, string Action)? Parse(string policyName)
{
if (!policyName.StartsWith(PolicyPrefix, StringComparison.Ordinal))
return null;
var body = policyName[PolicyPrefix.Length..];
var split = body.IndexOf(':');
if (split <= 0 || split == body.Length - 1)
return null;
return (body[..split], body[(split + 1)..]);
}
}
+66
View File
@@ -0,0 +1,66 @@
namespace ROLAC.API.Authorization;
/// <summary>
/// Canonical list of permission-controlled modules. The names are stored verbatim
/// in <see cref="Entities.RolePermission.Module"/> and used in <c>[HasPermission]</c>
/// attributes, so changing a string here is a breaking change requiring a data update.
/// </summary>
public static class Modules
{
public const string Members = "Members";
public const string Users = "Users";
public const string Givings = "Givings";
public const string GivingCategories = "GivingCategories";
public const string Expenses = "Expenses";
public const string ExpenseCategories = "ExpenseCategories";
public const string OfferingSessions = "OfferingSessions";
public const string Ministries = "Ministries";
public const string FinanceDashboard = "FinanceDashboard";
public const string MonthlyStatements = "MonthlyStatements";
public const string ChurchProfile = "ChurchProfile";
public const string Disbursements = "Disbursements";
public const string MealAttendance = "MealAttendance";
public const string Permissions = "Permissions";
public const string SystemLogs = "SystemLogs";
public const string AuditLogs = "AuditLogs";
/// <summary>All modules, in display order — drives the admin matrix UI.</summary>
public static readonly IReadOnlyList<string> All =
[
Members,
Users,
Givings,
GivingCategories,
Expenses,
ExpenseCategories,
OfferingSessions,
Ministries,
FinanceDashboard,
MonthlyStatements,
ChurchProfile,
Disbursements,
MealAttendance,
Permissions,
SystemLogs,
AuditLogs,
];
public static bool IsValid(string module) => All.Contains(module);
}
/// <summary>
/// The four actions a role can be granted on a module. The default HTTP-verb mapping
/// is GET→Read, POST/PUT/PATCH→Write, DELETE→Delete; "Approve" is applied explicitly
/// to state-transition endpoints (approve / finalize / issue / sign, etc.).
/// </summary>
public static class PermissionActions
{
public const string Read = "Read";
public const string Write = "Write";
public const string Delete = "Delete";
public const string Approve = "Approve";
public static readonly IReadOnlyList<string> All = [Read, Write, Delete, Approve];
public static bool IsValid(string action) => All.Contains(action);
}
@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Authorization;
using ROLAC.API.Services;
namespace ROLAC.API.Authorization;
/// <summary>
/// Evaluates <see cref="PermissionRequirement"/> against the user's roles.
/// <c>super_admin</c> always passes (bypass); otherwise the requirement succeeds if
/// ANY of the user's roles grants the requested module/action (union across roles).
/// </summary>
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
{
public const string SuperAdminRole = "super_admin";
private readonly IPermissionService _permissions;
public PermissionAuthorizationHandler(IPermissionService permissions)
=> _permissions = permissions;
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context, PermissionRequirement requirement)
{
// Roles live in "role" claims (RoleClaimType = "role", MapInboundClaims = false).
var roles = context.User.FindAll("role").Select(claim => claim.Value).ToList();
if (roles.Contains(SuperAdminRole))
{
context.Succeed(requirement);
return;
}
if (await _permissions.HasPermissionAsync(roles, requirement.Module, requirement.Action))
context.Succeed(requirement);
}
}
@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
namespace ROLAC.API.Authorization;
/// <summary>
/// Materializes <c>PERM:&lt;module&gt;:&lt;action&gt;</c> policies on demand so we never
/// have to register every module/action combination at startup. Any other policy name
/// (including the default and <c>Roles=</c> policies) is delegated to the framework's
/// default provider, so existing <c>[Authorize(Roles=...)]</c> usages keep working.
/// </summary>
public class PermissionPolicyProvider : IAuthorizationPolicyProvider
{
private readonly DefaultAuthorizationPolicyProvider _fallback;
public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
=> _fallback = new DefaultAuthorizationPolicyProvider(options);
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
var parsed = HasPermissionAttribute.Parse(policyName);
if (parsed is null)
return _fallback.GetPolicyAsync(policyName);
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddRequirements(new PermissionRequirement(parsed.Value.Module, parsed.Value.Action))
.Build();
return Task.FromResult<AuthorizationPolicy?>(policy);
}
}
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Authorization;
namespace ROLAC.API.Authorization;
/// <summary>
/// Authorization requirement carrying the module + action a request needs.
/// Materialized on demand by <see cref="PermissionPolicyProvider"/> from a policy
/// name of the form <c>PERM:&lt;module&gt;:&lt;action&gt;</c>.
/// </summary>
public class PermissionRequirement : IAuthorizationRequirement
{
public string Module { get; }
public string Action { get; }
public PermissionRequirement(string module, string action)
{
Module = module;
Action = action;
}
}
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/audit-logs")]
[Authorize]
public class AuditLogsController : ControllerBase
{
private readonly IAuditLogQueryService _svc;
public AuditLogsController(IAuditLogQueryService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.AuditLogs, PermissionActions.Read)]
public async Task<IActionResult> GetPaged([FromQuery] AuditLogQuery query)
=> Ok(await _svc.GetPagedAsync(query));
[HttpGet("{id:long}")]
[HasPermission(Modules.AuditLogs, PermissionActions.Read)]
public async Task<IActionResult> GetById(long id)
{
var dto = await _svc.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Category / action / level option lists for the filter UI.</summary>
[HttpGet("catalog")]
[HasPermission(Modules.AuditLogs, PermissionActions.Read)]
public IActionResult GetCatalog() => Ok(_svc.GetCatalog());
}
+216
View File
@@ -0,0 +1,216 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Auth;
using ROLAC.API.Entities;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
private const string CookieName = "rolac_rt";
private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
private readonly IAuthService _authService;
private readonly UserManager<AppUser> _userManager;
private readonly IWebHostEnvironment _env;
public AuthController(
IAuthService authService, UserManager<AppUser> userManager, IWebHostEnvironment env)
{
_authService = authService;
_userManager = userManager;
_env = env;
}
// -------------------------------------------------------------------------
// POST /api/auth/login
// -------------------------------------------------------------------------
/// <summary>Authenticates a user and returns an access token.</summary>
[HttpPost("login")]
[AllowAnonymous]
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
try
{
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var device = Request.Headers.UserAgent.FirstOrDefault();
var (response, raw) = await _authService.LoginAsync(request, ip, device);
SetRefreshCookie(raw);
return Ok(response);
}
catch (UnauthorizedAccessException ex)
{
return Unauthorized(new { message = ex.Message });
}
}
// -------------------------------------------------------------------------
// POST /api/auth/refresh
// -------------------------------------------------------------------------
/// <summary>
/// Rotates the refresh token (read from the HttpOnly cookie) and returns a
/// new access token.
/// </summary>
[HttpPost("refresh")]
[AllowAnonymous]
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Refresh()
{
var raw = Request.Cookies[CookieName];
if (string.IsNullOrEmpty(raw))
return Unauthorized(new { message = "Refresh token not found." });
try
{
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var (response, newRaw) = await _authService.RefreshAsync(raw, ip);
SetRefreshCookie(newRaw);
return Ok(response);
}
catch (UnauthorizedAccessException ex)
{
ClearRefreshCookie();
return Unauthorized(new { message = ex.Message });
}
}
// -------------------------------------------------------------------------
// GET /api/auth/me
// -------------------------------------------------------------------------
/// <summary>
/// Returns the current user's identity, roles, and effective permissions.
/// The SPA calls this on startup and after an admin edits the permission matrix
/// to refresh what the UI shows — without forcing a re-login.
/// </summary>
[HttpGet("me")]
[Authorize]
[ProducesResponseType(typeof(UserInfo), StatusCodes.Status200OK)]
public async Task<IActionResult> GetMe()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
if (string.IsNullOrEmpty(userId))
return Unauthorized();
var user = await _userManager.FindByIdAsync(userId);
if (user is null || !user.IsActive)
return Unauthorized();
var roles = await _userManager.GetRolesAsync(user);
return Ok(await _authService.BuildUserInfoAsync(user, roles));
}
// -------------------------------------------------------------------------
// GET /api/auth/claims (dev-only diagnostic)
// -------------------------------------------------------------------------
/// <summary>
/// Returns the raw claims ASP.NET Core parsed from the Bearer token.
/// Use this to debug 401 vs 403: if you get 200 here, the JWT validates fine;
/// if you then get 403 on a protected endpoint the role/permission isn't matching.
/// </summary>
[HttpGet("claims")]
[Authorize]
public IActionResult GetClaims()
{
var claims = User.Claims
.Select(c => new { c.Type, c.Value })
.ToList();
return Ok(new
{
isAuthenticated = User.Identity?.IsAuthenticated,
authenticationType = User.Identity?.AuthenticationType,
name = User.Identity?.Name,
claims,
});
}
// -------------------------------------------------------------------------
// POST /api/auth/logout
// -------------------------------------------------------------------------
/// <summary>Revokes the current refresh token and clears the cookie.</summary>
[HttpPost("logout")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> Logout()
{
var raw = Request.Cookies[CookieName];
if (!string.IsNullOrEmpty(raw))
await _authService.LogoutAsync(raw);
ClearRefreshCookie();
return NoContent();
}
// -------------------------------------------------------------------------
// POST /api/auth/change-password
// -------------------------------------------------------------------------
/// <summary>
/// Changes the current user's password. Requires the correct current password and a
/// new password meeting the configured policy. On success the user's *other* sessions
/// are revoked while the current session stays active.
/// </summary>
[HttpPost("change-password")]
[Authorize]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
if (string.IsNullOrEmpty(userId))
return Unauthorized();
var currentRefresh = Request.Cookies[CookieName];
var result = await _authService.ChangePasswordAsync(
userId, request.CurrentPassword, request.NewPassword, currentRefresh);
if (!result.Succeeded)
return BadRequest(new
{
message = string.Join(" ", result.Errors.Select(error => error.Description)),
});
return NoContent();
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/// <summary>
/// <c>Secure</c> is set to <c>true</c> everywhere except in the local
/// Development environment so that the cookie works over plain HTTP during
/// dev and smoke-test runs, but is always HTTPS-only in staging/production.
/// </summary>
private void SetRefreshCookie(string rawToken)
=> Response.Cookies.Append(CookieName, rawToken, new CookieOptions
{
HttpOnly = true,
Secure = !_env.IsDevelopment(),
SameSite = SameSiteMode.Strict,
MaxAge = TimeSpan.FromSeconds(CookieMaxAge),
Path = "/api/auth",
});
private void ClearRefreshCookie()
=> Response.Cookies.Delete(CookieName, new CookieOptions
{
HttpOnly = true,
Secure = !_env.IsDevelopment(),
SameSite = SameSiteMode.Strict,
Path = "/api/auth",
});
}
@@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/church-profile")]
[Authorize]
public class ChurchProfileController : ControllerBase
{
private readonly IChurchProfileService _svc;
public ChurchProfileController(IChurchProfileService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.ChurchProfile, PermissionActions.Read)]
public async Task<IActionResult> Get() => Ok(await _svc.GetAsync());
[HttpPut]
[HasPermission(Modules.ChurchProfile, PermissionActions.Write)]
public async Task<IActionResult> Update([FromBody] UpdateChurchProfileRequest r)
{
await _svc.UpdateAsync(r);
return NoContent();
}
}
@@ -0,0 +1,105 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/disbursements")]
[Authorize]
public class DisbursementsController : ControllerBase
{
private readonly IDisbursementService _svc;
public DisbursementsController(IDisbursementService svc) => _svc = svc;
[HttpGet("approved-unpaid")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetApprovedUnpaid()
=> Ok(await _svc.GetApprovedUnpaidGroupedAsync());
[HttpPost("issue")]
[HasPermission(Modules.Disbursements, PermissionActions.Write)]
public async Task<IActionResult> Issue([FromBody] IssueChecksRequest r)
{
try { return Ok(await _svc.IssueChecksAsync(r)); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpGet("checks")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetRegister(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? status = null,
[FromQuery] string? search = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
=> Ok(await _svc.GetRegisterAsync(page, pageSize, status, search, from, to));
[HttpGet("checks/{id:int}")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id)
{
var dto = await _svc.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost("checks/{id:int}/void")]
[HasPermission(Modules.Disbursements, PermissionActions.Delete)]
public async Task<IActionResult> Void(int id, [FromBody] VoidCheckRequest r)
{
try { await _svc.VoidAsync(id, r.Reason); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpGet("checks/{id:int}/pdf")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetPdf(int id)
{
var result = await _svc.RenderPdfAsync(id);
if (result is null) return NotFound();
return File(result.Value.stream, result.Value.contentType, result.Value.fileName);
}
[HttpGet("checks/{id:int}/receipt-pdf")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetReceiptPdf(int id)
{
var result = await _svc.RenderReceiptPdfAsync(id);
if (result is null) return NotFound();
return File(result.Value.stream, result.Value.contentType, result.Value.fileName);
}
[HttpPost("checks/{id:int}/acknowledge")]
[HasPermission(Modules.Disbursements, PermissionActions.Approve)]
[RequestSizeLimit(5_242_880)]
public async Task<IActionResult> Acknowledge(int id, [FromForm] IFormFile signature, [FromForm] string signedName)
{
if (signature is null || signature.Length == 0) return BadRequest(new { message = "No signature." });
if (string.IsNullOrWhiteSpace(signedName)) return BadRequest(new { message = "Signed name is required." });
var allowed = new[] { "image/png", "image/jpeg", "image/webp" };
if (!allowed.Contains(signature.ContentType)) return BadRequest(new { message = "Unsupported image type." });
try
{
await using var stream = signature.OpenReadStream();
await _svc.AcknowledgeReceiptAsync(id, stream, signature.FileName, signedName.Trim());
return NoContent();
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpGet("checks/{id:int}/signature")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetSignature(int id)
{
try
{
var result = await _svc.OpenSignatureAsync(id);
if (result is null) return NotFound();
return File(result.Value.stream, result.Value.contentType);
}
catch (KeyNotFoundException) { return NotFound(); }
}
}
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/expense-categories")]
[Authorize] // read (GetAll) is open to any authenticated user — the member self-service
// reimbursement form needs the category list. Write actions are finance-only below.
public class ExpenseCategoriesController : ControllerBase
{
private readonly IExpenseCategoryService _svc;
public ExpenseCategoriesController(IExpenseCategoryService svc) => _svc = svc;
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
=> Ok(await _svc.GetAllAsync(includeInactive));
[HttpPost("groups")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)
=> Ok(new { id = await _svc.CreateGroupAsync(r) });
[HttpPut("groups/{id:int}")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateExpenseGroupRequest r)
{ try { await _svc.UpdateGroupAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
[HttpDelete("groups/{id:int}")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Delete)]
public async Task<IActionResult> DeactivateGroup(int id)
{ try { await _svc.DeactivateGroupAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
[HttpPost("subcategories")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> CreateSub([FromBody] CreateExpenseSubCategoryRequest r)
{ try { return Ok(new { id = await _svc.CreateSubCategoryAsync(r) }); } catch (KeyNotFoundException) { return NotFound(); } }
[HttpPut("subcategories/{id:int}")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> UpdateSub(int id, [FromBody] UpdateExpenseSubCategoryRequest r)
{ try { await _svc.UpdateSubCategoryAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
[HttpDelete("subcategories/{id:int}")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Delete)]
public async Task<IActionResult> DeactivateSub(int id)
{ try { await _svc.DeactivateSubCategoryAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
}
@@ -0,0 +1,154 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
// Class is [Authorize] only — any authenticated member may submit/view their OWN
// reimbursements. Finance-level privileges (view-all, edit-any, approve) are resolved
// against the configurable permission matrix on the "Expenses" module.
[ApiController]
[Route("api/expenses")]
[Authorize]
public class ExpensesController : ControllerBase
{
private readonly IExpenseService _svc;
private readonly IPermissionService _perms;
public ExpensesController(IExpenseService svc, IPermissionService perms)
{
_svc = svc;
_perms = perms;
}
private List<string> Roles() => User.FindAll("role").Select(claim => claim.Value).ToList();
private bool IsSuperAdmin() => User.IsInRole(PermissionAuthorizationHandler.SuperAdminRole);
// Can manage any expense (edit/delete/upload on others' records). Maps to Expenses:Write.
private async Task<bool> CanManageAsync() =>
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Write);
// Can view all expenses (not just own). Maps to Expenses:Read (finance + pastor by default).
private async Task<bool> CanViewAllAsync() =>
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Read);
// User id lives in the "sub" claim (NameClaimType="sub"); NameIdentifier is absent at runtime.
private string CurrentUserId() =>
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "";
[HttpGet]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? search = null,
[FromQuery] int? ministryId = null, [FromQuery] int? categoryGroupId = null,
[FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null,
[FromQuery] int? subCategoryId = null, [FromQuery] string? statuses = null)
{
if (!await CanViewAllAsync()) return Forbid();
return Ok(await _svc.GetPagedAsync(page, pageSize, search, ministryId, categoryGroupId, status, from, to, subCategoryId, statuses));
}
[HttpGet("mine")]
public async Task<IActionResult> GetMine([FromQuery] string? status = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
return Ok(await _svc.GetMineAsync(CurrentUserId(), status, page, pageSize));
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
var dto = await _svc.GetByIdAsync(id);
if (dto is null) return NotFound();
if (!await CanViewAllAsync() && dto.SubmittedBy != CurrentUserId()) return Forbid();
return Ok(dto);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateExpenseRequest r)
{
try { return Ok(new { id = await _svc.CreateAsync(r, await CanManageAsync()) }); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateExpenseRequest r)
{
try { await _svc.UpdateAsync(id, r, await CanManageAsync()); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
try { await _svc.DeleteAsync(id, await CanManageAsync()); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpPost("{id:int}/submit")]
public async Task<IActionResult> Submit(int id)
{
try { await _svc.SubmitAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpPost("{id:int}/approve")]
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
public async Task<IActionResult> Approve(int id)
{
try { await _svc.ApproveAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpPost("{id:int}/reject")]
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
public async Task<IActionResult> Reject(int id, [FromBody] RejectExpenseRequest r)
{
try { await _svc.RejectAsync(id, r.ReviewNotes); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpPost("{id:int}/pay")]
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
public async Task<IActionResult> Pay(int id, [FromBody] PayExpenseRequest r)
{
try { await _svc.PayAsync(id, r.CheckNumber, r.PaidAt); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpPost("{id:int}/receipt")]
[RequestSizeLimit(10_485_760)]
public async Task<IActionResult> UploadReceipt(int id, IFormFile file)
{
if (file is null || file.Length == 0) return BadRequest(new { message = "No file." });
var allowed = new[] { "image/jpeg", "image/png", "image/webp", "application/pdf" };
if (!allowed.Contains(file.ContentType)) return BadRequest(new { message = "Unsupported file type." });
try
{
await using var stream = file.OpenReadStream();
await _svc.SaveReceiptAsync(id, stream, file.FileName, await CanManageAsync());
return NoContent();
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpGet("{id:int}/receipt")]
public async Task<IActionResult> GetReceipt(int id)
{
try
{
var result = await _svc.OpenReceiptAsync(id, await CanManageAsync());
if (result is null) return NotFound();
return File(result.Value.stream, result.Value.contentType);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException) { return Forbid(); }
}
}
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/finance-dashboard")]
[HasPermission(Modules.FinanceDashboard, PermissionActions.Read)]
public class FinanceDashboardController : ControllerBase
{
private readonly IFinanceDashboardService _svc;
public FinanceDashboardController(IFinanceDashboardService svc) => _svc = svc;
[HttpGet("summary")]
public async Task<IActionResult> Summary()
=> Ok(await _svc.GetSummaryAsync());
[HttpGet("income-expense")]
public async Task<IActionResult> IncomeExpense([FromQuery] DateOnly? from, [FromQuery] DateOnly? to)
=> Ok(await _svc.GetIncomeExpenseAsync(from, to));
[HttpGet("expense-breakdown")]
public async Task<IActionResult> ExpenseBreakdown(
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
[FromQuery] int? ministryId, [FromQuery] int? categoryGroupId)
=> Ok(await _svc.GetExpenseBreakdownAsync(from, to, ministryId, categoryGroupId));
}
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/giving-categories")]
[Authorize]
public class GivingCategoriesController : ControllerBase
{
private readonly IGivingCategoryService _svc;
public GivingCategoriesController(IGivingCategoryService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.GivingCategories, PermissionActions.Read)]
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
=> Ok(await _svc.GetAllAsync(includeInactive));
[HttpPost]
[HasPermission(Modules.GivingCategories, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateGivingCategoryRequest request)
{
var id = await _svc.CreateAsync(request);
return CreatedAtAction(nameof(GetAll), new { id }, new { id });
}
[HttpPut("{id:int}")]
[HasPermission(Modules.GivingCategories, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingCategoryRequest request)
{
try { await _svc.UpdateAsync(id, request); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
[HttpDelete("{id:int}")]
[HasPermission(Modules.GivingCategories, PermissionActions.Delete)]
public async Task<IActionResult> Deactivate(int id)
{
try { await _svc.DeactivateAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
}
@@ -0,0 +1,58 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/givings")]
[Authorize]
public class GivingsController : ControllerBase
{
private readonly IGivingService _svc;
public GivingsController(IGivingService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.Givings, PermissionActions.Read)]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] string? search = null, [FromQuery] int? categoryId = null,
[FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
=> Ok(await _svc.GetPagedAsync(page, pageSize, search, categoryId, from, to));
[HttpGet("{id:int}")]
[HasPermission(Modules.Givings, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id)
{
var dto = await _svc.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
[HasPermission(Modules.Givings, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateGivingRequest request)
{
var id = await _svc.CreateAsync(request);
return CreatedAtAction(nameof(GetById), new { id }, new { id });
}
[HttpPut("{id:int}")]
[HasPermission(Modules.Givings, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingRequest request)
{
try { await _svc.UpdateAsync(id, request); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpDelete("{id:int}")]
[HasPermission(Modules.Givings, PermissionActions.Delete)]
public async Task<IActionResult> Delete(int id)
{
try { await _svc.DeleteAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
}
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Data;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/health")]
[AllowAnonymous]
public class HealthController : ControllerBase
{
private readonly AppDbContext _db;
public HealthController(AppDbContext db) => _db = db;
[HttpGet]
public async Task<IActionResult> Get(CancellationToken cancellationToken)
{
var canConnectToDatabase = await _db.Database.CanConnectAsync(cancellationToken);
var payload = new
{
status = canConnectToDatabase ? "healthy" : "degraded",
database = canConnectToDatabase ? "up" : "down",
time = DateTimeOffset.UtcNow
};
return canConnectToDatabase ? Ok(payload) : StatusCode(503, payload);
}
}
@@ -0,0 +1,89 @@
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using ROLAC.API.DTOs.Notifications;
using ROLAC.API.Services.Notifications;
namespace ROLAC.API.Controllers;
/// <summary>
/// Anonymous Line webhook. Verifies the X-Line-Signature over the raw body, then dispatches
/// follow/message/join/leave events. Always returns 200 for valid payloads so Line does not retry;
/// returns 400 only on signature failure.
/// </summary>
[ApiController]
[Route("api/line")]
[AllowAnonymous]
public sealed class LineWebhookController : ControllerBase
{
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
private readonly ILineNotificationService _line;
private readonly IMessageChannel _channel;
private readonly LineOptions _options;
public LineWebhookController(
ILineNotificationService line, IMessageChannel channel, IOptions<LineOptions> options)
{
_line = line;
_channel = channel;
_options = options.Value;
}
[HttpPost("webhook")]
[RequestSizeLimit(262_144)]
public async Task<IActionResult> Webhook(CancellationToken ct)
{
using var reader = new StreamReader(Request.Body, Encoding.UTF8);
var rawBody = await reader.ReadToEndAsync(ct);
var signature = Request.Headers["X-Line-Signature"].FirstOrDefault();
if (!LineSignature.IsValid(_options.ChannelSecret, Encoding.UTF8.GetBytes(rawBody), signature))
return BadRequest();
var payload = JsonSerializer.Deserialize<LineWebhookPayload>(rawBody, JsonOpts);
if (payload?.Events is not null)
foreach (var evt in payload.Events)
await DispatchAsync(evt, ct);
return Ok();
}
private async Task DispatchAsync(LineWebhookEvent evt, CancellationToken ct)
{
switch (evt.Type)
{
case "follow":
if (evt.ReplyToken is not null)
await _channel.ReplyAsync(evt.ReplyToken, "歡迎!請輸入您的綁定碼以連結教會帳號。", ct);
break;
case "message":
if (evt.Message?.Type == "text"
&& evt.Source?.UserId is { } userId
&& evt.Message.Text is { } text)
{
var result = await _line.TryBindMemberAsync(userId, text, ct);
if (evt.ReplyToken is not null)
await _channel.ReplyAsync(evt.ReplyToken, result.Message, ct);
}
break;
case "join":
if (evt.Source?.GroupId is { } joinGroupId)
{
await _line.RegisterGroupAsync(joinGroupId, ct);
if (evt.ReplyToken is not null)
await _channel.ReplyAsync(evt.ReplyToken, "已加入群組,請至後台命名此群組。", ct);
}
break;
case "leave":
if (evt.Source?.GroupId is { } leaveGroupId)
await _line.DeactivateGroupAsync(leaveGroupId, ct);
break;
}
}
}
@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/meal-attendance")]
public class MealAttendanceController : ControllerBase
{
private readonly IMealAttendanceService _svc;
public MealAttendanceController(IMealAttendanceService svc) => _svc = svc;
/// <summary>Today's live counts. Public — feeds the volunteer counter page on first load.</summary>
[HttpGet("today")]
[AllowAnonymous]
public async Task<IActionResult> GetToday()
=> Ok(await _svc.GetOrCreateAsync(_svc.ServiceDay));
/// <summary>Daily counts within a date range, for the back-office dashboard chart.</summary>
[HttpGet]
[Authorize]
public async Task<IActionResult> GetRange([FromQuery] DateOnly from, [FromQuery] DateOnly to)
=> Ok(await _svc.GetRangeAsync(from, to));
}
@@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Members;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/members")]
[Authorize]
public class MembersController : ControllerBase
{
private readonly IMemberService _members;
public MembersController(IMemberService members) => _members = members;
/// <summary>GET /api/members?page=1&pageSize=20&search=Chen&status=Member&hasUser=false</summary>
[HttpGet]
[HasPermission(Modules.Members, PermissionActions.Read)]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? search = null,
[FromQuery] string? status = null,
[FromQuery] bool? hasUser = null)
=> Ok(await _members.GetPagedAsync(page, pageSize, search, status, hasUser));
/// <summary>GET /api/members/{id}</summary>
[HttpGet("{id:int}")]
[HasPermission(Modules.Members, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id)
{
var dto = await _members.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>POST /api/members</summary>
[HttpPost]
[HasPermission(Modules.Members, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateMemberRequest request)
{
var id = await _members.CreateAsync(request);
return CreatedAtAction(nameof(GetById), new { id }, new { id });
}
/// <summary>PUT /api/members/{id}</summary>
[HttpPut("{id:int}")]
[HasPermission(Modules.Members, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateMemberRequest request)
{
try { await _members.UpdateAsync(id, request); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
/// <summary>DELETE /api/members/{id} — soft delete</summary>
[HttpDelete("{id:int}")]
[HasPermission(Modules.Members, PermissionActions.Delete)]
public async Task<IActionResult> Delete(int id)
{
try { await _members.DeleteAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
}
@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/ministries")]
[Authorize]
public class MinistriesController : ControllerBase
{
private readonly IMinistryService _svc;
public MinistriesController(IMinistryService svc) => _svc = svc;
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
=> Ok(await _svc.GetAllAsync(includeInactive));
}
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/monthly-statements")]
[Authorize]
public class MonthlyStatementsController : ControllerBase
{
private readonly IMonthlyStatementService _svc;
public MonthlyStatementsController(IMonthlyStatementService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Read)]
public async Task<IActionResult> GetAll([FromQuery] int? year = null)
=> Ok(await _svc.GetAllAsync(year));
[HttpGet("{id:int}")]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id)
{
var dto = await _svc.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateMonthlyStatementRequest r)
{
try { return Ok(new { id = await _svc.CreateAsync(r) }); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpPut("{id:int}")]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateMonthlyStatementRequest r)
{
try { await _svc.UpdateAsync(id, r); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpPost("{id:int}/finalize")]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Approve)]
public async Task<IActionResult> Finalize(int id)
{
try { await _svc.FinalizeAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
}
@@ -0,0 +1,95 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Notifications;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Notifications;
namespace ROLAC.API.Controllers;
/// <summary>
/// Admin endpoints for the notification module (API-only phase). Binding-code generation, group
/// management, send history, and manual send — the manual send endpoints are the only way to fire
/// a message before a UI exists; programmatic callers use the services directly.
/// </summary>
[ApiController]
[Route("api/notifications")]
[Authorize]
public sealed class NotificationsController : ControllerBase
{
private readonly IEmailService _email;
private readonly ILineNotificationService _line;
private readonly AppDbContext _db;
private readonly CurrentUserAccessor _currentUser;
public NotificationsController(
IEmailService email, ILineNotificationService line,
AppDbContext db, CurrentUserAccessor currentUser)
{
_email = email;
_line = line;
_db = db;
_currentUser = currentUser;
}
[HttpPost("members/{id:int}/line-binding-code")]
public async Task<IActionResult> GenerateBindingCode(int id, CancellationToken ct)
=> Ok(new { code = await _line.GenerateLineBindingCodeAsync(id, ct) });
[HttpGet("groups")]
public async Task<IActionResult> Groups(CancellationToken ct)
=> Ok(await _db.MessagingGroups
.OrderBy(g => g.Id)
.Select(g => new { g.Id, g.Name, g.IsActive, g.RegisteredAt })
.ToListAsync(ct));
[HttpPut("groups/{id:int}")]
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateGroupRequest request, CancellationToken ct)
{
var group = await _db.MessagingGroups.FirstOrDefaultAsync(g => g.Id == id, ct);
if (group is null) return NotFound();
group.Name = request.Name;
group.IsActive = request.IsActive;
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpGet("history")]
public async Task<IActionResult> History(
[FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken ct = default)
{
var size = Math.Clamp(pageSize, 1, 200);
var skip = (Math.Max(page, 1) - 1) * size;
var query = _db.NotificationLogs.OrderByDescending(l => l.SentAt);
var total = await query.CountAsync(ct);
var items = await query
.Skip(skip).Take(size)
.Select(l => new
{
l.Id, l.Channel, l.TargetType, l.TargetExternalId, l.Subject,
l.Status, l.Error, l.SentByUserId, l.SentAt,
})
.ToListAsync(ct);
return Ok(new { total, items });
}
[HttpPost("send-line")]
public async Task<IActionResult> SendLine([FromBody] SendLineRequest request, CancellationToken ct)
=> Ok(await _line.SendLineAsync(
request.Body, request.MemberIds ?? [], request.GroupIds ?? [],
_currentUser.UserIdOrSystem, ct));
[HttpPost("send-email")]
public async Task<IActionResult> SendEmail([FromBody] SendEmailRequest request, CancellationToken ct)
=> Ok(await _email.SendAsync(new EmailMessage(
MemberIds: request.MemberIds ?? [],
Addresses: request.Addresses ?? [],
Subject: request.Subject,
HtmlBody: request.HtmlBody,
Attachments: null,
SentByUserId: _currentUser.UserIdOrSystem), ct));
}
@@ -0,0 +1,112 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.DTOs.Members;
using ROLAC.API.Hubs;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
/// <summary>
/// Anonymous endpoints powering the mobile Sunday offering-entry page. The page
/// has no login yet, so it cannot reach the auth-gated members/categories/
/// offering-sessions APIs — these expose just what it needs (active categories,
/// a name-only member typeahead, and append-one-line).
/// </summary>
[ApiController]
[Route("api/offering-entry")]
[AllowAnonymous]
public class OfferingEntryController : ControllerBase
{
private readonly IOfferingSessionService _sessions;
private readonly IGivingCategoryService _categories;
private readonly IMemberService _members;
private readonly IHubContext<OfferingEntryHub> _hub;
public OfferingEntryController(
IOfferingSessionService sessions,
IGivingCategoryService categories,
IMemberService members,
IHubContext<OfferingEntryHub> hub)
{
_sessions = sessions;
_categories = categories;
_members = members;
_hub = hub;
}
// Seed the page in one round-trip: active categories + today's session state.
[HttpGet("bootstrap")]
public async Task<IActionResult> Bootstrap([FromQuery] DateOnly date)
=> Ok(new OfferingEntryBootstrapDto
{
SessionDate = date.ToString("yyyy-MM-dd"),
Categories = await _categories.GetAllAsync(false),
Summary = await _sessions.GetEntrySummaryAsync(date),
});
// Name-only member suggestions for the giver typeahead.
[HttpGet("members")]
public async Task<IActionResult> SearchMembers([FromQuery] string? search, [FromQuery] int take = 10)
=> Ok(await _sessions.SearchMembersForEntryAsync(search, Math.Clamp(take, 1, 25)));
// Quick-add a giver who isn't on file yet (created as a Visitor). Reuses the
// member service directly — role checks live on MembersController, so this
// anonymous path is the intended public entry point for the mobile page.
[HttpPost("members")]
public async Task<IActionResult> QuickAddMember([FromBody] QuickAddMemberRequest request)
{
var id = await _members.CreateAsync(new CreateMemberRequest
{
FirstName_en = request.FirstName_en,
LastName_en = request.LastName_en,
NickName = request.NickName,
FirstName_zh = request.FirstName_zh,
LastName_zh = request.LastName_zh,
PhoneCell = request.PhoneCell,
Status = "Visitor",
Country = "USA",
LanguagePreference = "en",
});
return Ok(new MemberTypeaheadDto
{
Id = id, NickName = request.NickName,
FirstName_en = request.FirstName_en, LastName_en = request.LastName_en,
});
}
// Append one offering line to the date's session (find-or-create), then
// broadcast it to everyone viewing that date.
[HttpPost("lines")]
public async Task<IActionResult> AppendLine([FromBody] AppendOfferingLineRequest request)
{
var result = await _sessions.AppendLineAsync(request.Date, request.Line);
await _hub.Clients.Group(result.SessionDate).SendAsync("LineAdded", result);
return Ok(result);
}
// ── Paper-proof PDF for the date's session (merged client-side) ──────────
// Date-keyed so the anonymous page (which has no session id) can attach the
// count sheet / envelope photos. Mirrors OfferingSessionsController's proof
// validation; the desktop session page reviews/deletes the result.
[HttpGet("proof")]
public async Task<IActionResult> GetProof([FromQuery] DateOnly date)
{
var result = await _sessions.OpenProofForDateAsync(date);
if (result is null) return NoContent(); // no session/proof yet — client merges nothing
return File(result.Value.stream, result.Value.contentType);
}
[HttpPost("proof")]
[RequestSizeLimit(52_428_800)] // 50 MB — a merged multi-image PDF is larger than one receipt
public async Task<IActionResult> UploadProof([FromForm] DateOnly date, IFormFile file)
{
if (file is null || file.Length == 0) return BadRequest(new { message = "No file." });
if (file.ContentType != "application/pdf") return BadRequest(new { message = "Proof must be a PDF." });
await using var stream = file.OpenReadStream();
await _sessions.SaveProofForDateAsync(date, stream, file.FileName);
return NoContent();
}
}
@@ -0,0 +1,105 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/offering-sessions")]
[Authorize]
public class OfferingSessionsController : ControllerBase
{
private readonly IOfferingSessionService _svc;
public OfferingSessionsController(IOfferingSessionService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
=> Ok(await _svc.GetPagedAsync(page, pageSize, from, to));
[HttpGet("check-date")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
public async Task<IActionResult> CheckDate([FromQuery] DateOnly date)
=> Ok(new { exists = await _svc.DateExistsAsync(date) });
[HttpGet("{id:int}")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id)
{
var dto = await _svc.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateOfferingSessionRequest request)
{
try
{
var id = await _svc.CreateAsync(request);
return CreatedAtAction(nameof(GetById), new { id }, new { id });
}
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpPost("{id:int}/reopen")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Approve)]
public async Task<IActionResult> Reopen(int id)
{
try { await _svc.ReopenAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpPut("{id:int}")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
public async Task<IActionResult> Replace(int id, [FromBody] CreateOfferingSessionRequest request)
{
try { await _svc.ReplaceAsync(id, request); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
// ── Paper-proof PDF (merged client-side, one file per session) ───────────
[HttpPost("{id:int}/proof")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
[RequestSizeLimit(52_428_800)] // 50 MB — a merged multi-image PDF is larger than one receipt
public async Task<IActionResult> UploadProof(int id, IFormFile file)
{
if (file is null || file.Length == 0) return BadRequest(new { message = "No file." });
if (file.ContentType != "application/pdf") return BadRequest(new { message = "Proof must be a PDF." });
try
{
await using var stream = file.OpenReadStream();
await _svc.SaveProofAsync(id, stream, file.FileName);
return NoContent();
}
catch (KeyNotFoundException) { return NotFound(); }
}
[HttpGet("{id:int}/proof")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
public async Task<IActionResult> GetProof(int id)
{
try
{
var result = await _svc.OpenProofAsync(id);
if (result is null) return NotFound();
return File(result.Value.stream, result.Value.contentType);
}
catch (KeyNotFoundException) { return NotFound(); }
}
[HttpDelete("{id:int}/proof")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Delete)]
public async Task<IActionResult> DeleteProof(int id)
{
try { await _svc.DeleteProofAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
}
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Permissions;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
/// <summary>
/// Admin surface for the configurable RBAC matrix. Restricted to super_admin —
/// the role that governs who governs everyone else.
/// </summary>
[ApiController]
[Route("api/permissions")]
[Authorize(Roles = "super_admin")]
public class PermissionsController : ControllerBase
{
private readonly IPermissionService _permissions;
public PermissionsController(IPermissionService permissions) => _permissions = permissions;
/// <summary>GET /api/permissions — the full role × module matrix.</summary>
[HttpGet]
public async Task<IActionResult> GetMatrix() => Ok(await _permissions.GetMatrixAsync());
/// <summary>GET /api/permissions/catalog — module + action names for the grid.</summary>
[HttpGet("catalog")]
public IActionResult GetCatalog() => Ok(new PermissionCatalogDto
{
Modules = Modules.All,
Actions = PermissionActions.All,
});
/// <summary>PUT /api/permissions/{roleName} — replaces a role's grants.</summary>
[HttpPut("{roleName}")]
public async Task<IActionResult> UpdateRole(string roleName, [FromBody] UpdateRolePermissionsRequest request)
{
try
{
await _permissions.UpsertRoleAsync(roleName, request.Modules);
return NoContent();
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
}
}
@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Logging;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/system-logs")]
[Authorize]
public class SystemLogsController : ControllerBase
{
private readonly ISystemLogQueryService _svc;
public SystemLogsController(ISystemLogQueryService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.SystemLogs, PermissionActions.Read)]
public async Task<IActionResult> GetPaged([FromQuery] SystemLogQuery query)
=> Ok(await _svc.GetPagedAsync(query));
[HttpGet("{id:long}")]
[HasPermission(Modules.SystemLogs, PermissionActions.Read)]
public async Task<IActionResult> GetById(long id)
{
var dto = await _svc.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>All six severities, so the UI can offer every filter option regardless of data.</summary>
[HttpGet("levels")]
[HasPermission(Modules.SystemLogs, PermissionActions.Read)]
public IActionResult GetLevels() => Ok(Enum.GetNames<LogLevelEnum>());
}
@@ -0,0 +1,85 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Users;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/users")]
[Authorize]
public class UsersController : ControllerBase
{
private readonly IUserManagementService _users;
public UsersController(IUserManagementService users) => _users = users;
/// <summary>GET /api/users?page=1&pageSize=20&search=Chris</summary>
[HttpGet]
[HasPermission(Modules.Users, PermissionActions.Read)]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? search = null)
=> Ok(await _users.GetPagedAsync(page, pageSize, search));
/// <summary>GET /api/users/{id}</summary>
[HttpGet("{id}")]
[HasPermission(Modules.Users, PermissionActions.Read)]
public async Task<IActionResult> GetById(string id)
{
var dto = await _users.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>
/// POST /api/users — creates account for a Member, returns { userId, tempPassword }.
/// TempPassword is returned ONCE — show it to the admin and never log it.
/// </summary>
[HttpPost]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateUserRequest request)
{
try
{
var result = await _users.CreateAsync(request);
return Ok(result);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { message = ex.Message });
}
}
/// <summary>PUT /api/users/{id} — update email, roles, IsActive</summary>
[HttpPut("{id}")]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> Update(string id, [FromBody] UpdateUserRequest request)
{
try { await _users.UpdateAsync(id, request); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
}
/// <summary>DELETE /api/users/{id} — deactivates account (IsActive=false), does not delete</summary>
[HttpDelete("{id}")]
[HasPermission(Modules.Users, PermissionActions.Delete)]
public async Task<IActionResult> Deactivate(string id)
{
try { await _users.DeactivateAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
/// <summary>POST /api/users/{id}/reset-password — returns new temp password</summary>
[HttpPost("{id}/reset-password")]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> ResetPassword(string id)
{
try
{
var pwd = await _users.ResetPasswordAsync(id);
return Ok(new { tempPassword = pwd });
}
catch (KeyNotFoundException) { return NotFound(); }
}
}
@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Mvc;
namespace ROLAC.API.Controllers
{
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
private readonly ILogger<WeatherForecastController> _logger;
public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
}
}
@@ -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!;
}
+16
View File
@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Auth;
public class LoginRequest
{
[Required]
[EmailAddress]
[MaxLength(256)]
public string Email { get; set; } = null!;
[Required]
[MinLength(8)]
[MaxLength(128)]
public string Password { get; set; } = null!;
}
+28
View File
@@ -0,0 +1,28 @@
using ROLAC.API.DTOs.Permissions;
namespace ROLAC.API.DTOs.Auth;
public class LoginResponse
{
/// <summary>Short-lived JWT (15 min). Store in memory — never in localStorage.</summary>
public string AccessToken { get; set; } = null!;
/// <summary>Seconds until the access token expires. Always 900 (15 × 60).</summary>
public int ExpiresIn { get; set; }
public UserInfo User { get; set; } = null!;
}
public class UserInfo
{
public string Id { get; set; } = null!;
public string Email { get; set; } = null!;
public IList<string> Roles { get; set; } = [];
public string LanguagePreference { get; set; } = "en";
/// <summary>
/// Effective permissions (union across the user's roles), keyed by module name.
/// Lets the SPA hide nav/buttons. Authoritative enforcement is server-side.
/// </summary>
public Dictionary<string, ModuleActions> Permissions { get; set; } = [];
}
@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Disbursement;
public class ChurchProfileDto
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? ZipCode { get; set; }
public string? BankName { get; set; }
public string? BankAccountNumber { get; set; }
public string? BankRoutingNumber { get; set; }
public int NextCheckNumber { get; set; }
}
public class UpdateChurchProfileRequest
{
[Required, MaxLength(200)] public string Name { get; set; } = "";
[MaxLength(500)] public string? Address { get; set; }
[MaxLength(100)] public string? City { get; set; }
[MaxLength(50)] public string? State { get; set; }
[MaxLength(20)] public string? ZipCode { get; set; }
[MaxLength(200)] public string? BankName { get; set; }
[MaxLength(50)] public string? BankAccountNumber { get; set; }
[MaxLength(50)] public string? BankRoutingNumber { get; set; }
[Range(1, int.MaxValue)] public int NextCheckNumber { get; set; }
}
@@ -0,0 +1,107 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Disbursement;
// ── Approved-unpaid expenses, grouped by payee (the issue-check worklist) ──────
public class ExpenseLineDto
{
public int ExpenseId { get; set; }
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
public string Description { get; set; } = "";
public decimal Amount { get; set; }
public string MinistryName { get; set; } = "";
public string CategoryName { get; set; } = "";
}
public class PayeeGroupDto
{
public string PayeeType { get; set; } = "Vendor"; // Vendor | Member
public int? MemberId { get; set; }
public string? VendorKey { get; set; } // normalized vendor name (grouping key)
public string PayeeName { get; set; } = "";
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public decimal TotalAmount { get; set; }
public List<ExpenseLineDto> Lines { get; set; } = [];
}
// ── Issue checks ──────────────────────────────────────────────────────────────
public class PayeeCheckInstruction
{
[Required] public string PayeeType { get; set; } = "Vendor";
public int? MemberId { get; set; }
public string? VendorKey { get; set; }
[Required, MaxLength(200)] public string PayeeName { get; set; } = "";
[MaxLength(500)] public string? Address { get; set; }
[MaxLength(100)] public string? City { get; set; }
[MaxLength(50)] public string? State { get; set; }
[MaxLength(20)] public string? Zip { get; set; }
[MaxLength(50)] public string? CheckNumberOverride { get; set; }
[MaxLength(500)] public string? Memo { get; set; }
[Required, MinLength(1)] public List<int> ExpenseIds { get; set; } = [];
}
public class IssueChecksRequest
{
[Required] public DateOnly CheckDate { get; set; }
[Required, MinLength(1)] public List<PayeeCheckInstruction> Payees { get; set; } = [];
}
public class IssuedCheckDto
{
public int CheckId { get; set; }
public string CheckNumber { get; set; } = "";
public string PayeeName { get; set; } = "";
public decimal Amount { get; set; }
}
public class IssueChecksResultDto
{
public List<IssuedCheckDto> Created { get; set; } = [];
}
// ── Check register / detail ───────────────────────────────────────────────────
public class CheckListItemDto
{
public int Id { get; set; }
public string CheckNumber { get; set; } = "";
public string CheckDate { get; set; } = ""; // yyyy-MM-dd
public decimal Amount { get; set; }
public string PayeeType { get; set; } = "";
public string PayeeName { get; set; } = "";
public string Status { get; set; } = "";
public int LineCount { get; set; }
public bool Signed { get; set; }
public string? ReceiptSignedName { get; set; }
public DateTimeOffset? ReceiptSignedAt { get; set; }
}
public class CheckLineDto
{
public int ExpenseId { get; set; }
public string Description { get; set; } = "";
public decimal Amount { get; set; }
}
public class CheckDetailDto : CheckListItemDto
{
public int? MemberId { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? Memo { get; set; }
public string? VoidReason { get; set; }
public DateTimeOffset? VoidedAt { get; set; }
public DateTimeOffset IssuedAt { get; set; }
public List<CheckLineDto> Lines { get; set; } = [];
}
public class VoidCheckRequest
{
[MaxLength(500)] public string? Reason { get; set; }
}
@@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Expense;
public class ExpenseSubCategoryDto
{
public int Id { get; set; }
public int GroupId { get; set; }
public string Name_en { get; set; } = "";
public string? Name_zh { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; }
}
public class ExpenseCategoryGroupDto
{
public int Id { get; set; }
public string Name_en { get; set; } = "";
public string? Name_zh { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; }
public List<ExpenseSubCategoryDto> SubCategories { get; set; } = [];
}
public class CreateExpenseGroupRequest
{
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
[MaxLength(200)] public string? Name_zh { get; set; }
public int SortOrder { get; set; }
}
public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest
{
public bool IsActive { get; set; } = true;
}
public class CreateExpenseSubCategoryRequest
{
[Required] public int GroupId { get; set; }
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
[MaxLength(200)] public string? Name_zh { get; set; }
public int SortOrder { get; set; }
}
public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest
{
public bool IsActive { get; set; } = true;
}
+59
View File
@@ -0,0 +1,59 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Expense;
public class ExpenseListItemDto
{
public int Id { get; set; }
public string Type { get; set; } = "";
public string Status { get; set; } = "";
public decimal Amount { get; set; }
public string Description { get; set; } = "";
public int MinistryId { get; set; }
public string MinistryName { get; set; } = "";
public int CategoryGroupId { get; set; }
public string CategoryGroupName { get; set; } = "";
public int SubCategoryId { get; set; }
public string SubCategoryName { get; set; } = "";
public string? VendorName { get; set; }
public int? MemberId { get; set; }
public string? MemberName { get; set; }
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
public bool HasReceipt { get; set; }
public string? CheckNumber { get; set; }
}
public class ExpenseDto : ExpenseListItemDto
{
public string? Notes { get; set; }
public string? ReviewNotes { get; set; }
public string? SubmittedBy { get; set; }
public DateTimeOffset? SubmittedAt { get; set; }
public DateTimeOffset? ReviewedAt { get; set; }
public DateTimeOffset? PaidAt { get; set; }
}
public class CreateExpenseRequest
{
[Required] public string Type { get; set; } = "StaffReimbursement"; // VendorPayment|StaffReimbursement
[Required] public int MinistryId { get; set; }
[Required] public int CategoryGroupId { get; set; }
[Required] public int SubCategoryId { get; set; }
[Range(0.01, 9_999_999)] public decimal Amount { get; set; }
[Required, MaxLength(500)] public string Description { get; set; } = "";
[MaxLength(200)] public string? VendorName { get; set; }
public int? MemberId { get; set; } // ignored for self-service (server uses caller)
[MaxLength(50)] public string? CheckNumber { get; set; }
[Required] public DateOnly ExpenseDate { get; set; }
public string? Notes { get; set; }
}
public class UpdateExpenseRequest : CreateExpenseRequest { }
public class RejectExpenseRequest
{
[MaxLength(500)] public string? ReviewNotes { get; set; }
}
public class PayExpenseRequest
{
[MaxLength(50)] public string? CheckNumber { get; set; }
public DateOnly? PaidAt { get; set; }
}
@@ -0,0 +1,35 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Expense;
public class MonthlyStatementDto
{
public int Id { get; set; }
public int Year { get; set; }
public int Month { get; set; }
public decimal OpeningBalance { get; set; }
public decimal TotalGiving { get; set; }
public decimal TotalOtherIncome { get; set; }
public decimal TotalExpenses { get; set; }
public decimal CalculatedClosingBalance { get; set; }
public decimal BankStatementBalance { get; set; }
public decimal Difference { get; set; }
public string? Notes { get; set; }
public bool IsFinalized { get; set; }
}
public class CreateMonthlyStatementRequest
{
[Range(2000, 2100)] public int Year { get; set; }
[Range(1, 12)] public int Month { get; set; }
public decimal OpeningBalance { get; set; }
public decimal TotalOtherIncome { get; set; }
public decimal BankStatementBalance { get; set; }
public string? Notes { get; set; }
}
public class UpdateMonthlyStatementRequest
{
public decimal OpeningBalance { get; set; }
public decimal TotalOtherIncome { get; set; }
public decimal BankStatementBalance { get; set; }
public string? Notes { get; set; }
}
@@ -0,0 +1,25 @@
namespace ROLAC.API.DTOs.Finance;
/// <summary>All-time finance position for the dashboard balance card.</summary>
public class FinanceSummaryDto
{
public decimal TotalIncome { get; set; } // all-time sum of Giving.Amount
public decimal TotalExpenses { get; set; } // all-time Paid+Approved expenses
public decimal Balance { get; set; } // TotalIncome - TotalExpenses
}
/// <summary>Income vs expense totals for a date range (the income/expense pie).</summary>
public class IncomeExpenseDto
{
public decimal Income { get; set; } // Givings in [from,to]
public decimal Expense { get; set; } // Paid+Approved expenses in [from,to]
}
/// <summary>One slice of the expense drill-down pie. Id is a ministry / group / sub-category id by level.</summary>
public class BreakdownSliceDto
{
public int Id { get; set; }
public string Name_en { get; set; } = "";
public string? Name_zh { get; set; }
public decimal Amount { get; set; }
}
@@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Giving;
// Body of POST /api/offering-entry/lines — one offering line plus the date of the
// session it belongs to (find-or-create that day's session, append the line).
public class AppendOfferingLineRequest
{
[Required] public DateOnly Date { get; set; }
[Required] public OfferingGivingLineRequest Line { get; set; } = new();
}
@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Giving;
public class CreateGivingCategoryRequest
{
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
[MaxLength(200)] public string? Name_zh { get; set; }
[MaxLength(500)] public string? Description_en { get; set; }
[MaxLength(500)] public string? Description_zh { get; set; }
public int SortOrder { get; set; }
}
@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Giving;
public class CreateGivingRequest
{
public int? MemberId { get; set; }
[Required] public int GivingCategoryId { get; set; }
[Range(0.01, 9999999)] public decimal Amount { get; set; }
[Required, MaxLength(20)] public string PaymentMethod { get; set; } = "Cash";
[MaxLength(50)] public string? CheckNumber { get; set; }
[MaxLength(100)] public string? ZelleReferenceCode { get; set; }
[MaxLength(100)] public string? PayPalTransactionId { get; set; }
public DateOnly GivingDate { get; set; }
public bool IsAnonymous { get; set; }
[MaxLength(500)] public string? Notes { get; set; }
}
@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Giving;
public class CreateOfferingSessionRequest
{
[Required] public DateOnly SessionDate { get; set; }
public decimal CashTotal { get; set; }
public decimal CheckTotal { get; set; }
public string? Notes { get; set; }
public List<OfferingGivingLineRequest> Givings { get; set; } = [];
}
@@ -0,0 +1,12 @@
namespace ROLAC.API.DTOs.Giving;
public class GivingCategoryDto
{
public int Id { get; set; }
public string Name_en { get; set; } = "";
public string? Name_zh { get; set; }
public string? Description_en { get; set; }
public string? Description_zh { get; set; }
public bool IsActive { get; set; }
public int SortOrder { get; set; }
}
+18
View File
@@ -0,0 +1,18 @@
namespace ROLAC.API.DTOs.Giving;
public class GivingDto
{
public int Id { get; set; }
public int? MemberId { get; set; }
public string? MemberName { get; set; }
public int GivingCategoryId { get; set; }
public int? OfferingSessionId { get; set; }
public decimal Amount { get; set; }
public string PaymentMethod { get; set; } = "";
public string? CheckNumber { get; set; }
public string? ZelleReferenceCode { get; set; }
public string? PayPalTransactionId { get; set; }
public DateOnly GivingDate { get; set; }
public bool IsAnonymous { get; set; }
public string? Notes { get; set; }
}
@@ -0,0 +1,15 @@
namespace ROLAC.API.DTOs.Giving;
public class GivingListItemDto
{
public int Id { get; set; }
public int? MemberId { get; set; }
public string? MemberName { get; set; }
public int GivingCategoryId { get; set; }
public string CategoryName { get; set; } = "";
public decimal Amount { get; set; }
public string PaymentMethod { get; set; } = "";
public string GivingDate { get; set; } = ""; // ISO yyyy-MM-dd
public bool IsAnonymous { get; set; }
public int? OfferingSessionId { get; set; }
}
@@ -0,0 +1,12 @@
namespace ROLAC.API.DTOs.Giving;
// Minimal member fields exposed to the anonymous mobile offering-entry page —
// just enough for the giver typeahead to render a display name (matches the
// Angular memberDisplayName helper: NickName ?? FirstName_en, plus LastName_en).
public class MemberTypeaheadDto
{
public int Id { get; set; }
public string? NickName { get; set; }
public string FirstName_en { get; set; } = "";
public string LastName_en { get; set; } = "";
}
@@ -0,0 +1,10 @@
namespace ROLAC.API.DTOs.Giving;
// One-shot payload that seeds the mobile offering-entry page: the active giving
// categories for the Type dropdown and the current state of today's session.
public class OfferingEntryBootstrapDto
{
public string SessionDate { get; set; } = ""; // yyyy-MM-dd
public List<GivingCategoryDto> Categories { get; set; } = [];
public OfferingEntrySummaryDto Summary { get; set; } = new();
}
@@ -0,0 +1,14 @@
namespace ROLAC.API.DTOs.Giving;
// Returned from POST /api/offering-entry/lines and broadcast over the
// OfferingEntryHub: the line just added plus the session's new running totals,
// so every connected client (other phones + the desktop page) can update live.
public class OfferingEntryLineAddedDto
{
public int SessionId { get; set; }
public string SessionDate { get; set; } = ""; // yyyy-MM-dd
public string Status { get; set; } = "";
public decimal SystemTotal { get; set; }
public int LineCount { get; set; }
public OfferingGivingLineDto Line { get; set; } = new();
}
@@ -0,0 +1,15 @@
namespace ROLAC.API.DTOs.Giving;
// A day's offering session as the mobile page sees it: the running total/line
// count plus the lines already recorded. SessionId is null when no session
// exists for the date yet (nothing entered today).
public class OfferingEntrySummaryDto
{
public int? SessionId { get; set; }
public string SessionDate { get; set; } = ""; // yyyy-MM-dd
public string? Status { get; set; } // null when no session yet
public decimal SystemTotal { get; set; }
public int LineCount { get; set; }
public bool HasProof { get; set; } // a merged paper-proof PDF is attached to this session
public List<OfferingGivingLineDto> Lines { get; set; } = [];
}
@@ -0,0 +1,17 @@
namespace ROLAC.API.DTOs.Giving;
public class OfferingGivingLineDto
{
public int Id { get; set; }
public int? MemberId { get; set; }
public string? MemberName { get; set; }
public int GivingCategoryId { get; set; }
public string CategoryName { get; set; } = "";
public decimal Amount { get; set; }
public string PaymentMethod { get; set; } = "";
public string? CheckNumber { get; set; }
public string? ZelleReferenceCode { get; set; }
public string? PayPalTransactionId { get; set; }
public bool IsAnonymous { get; set; }
public string? Notes { get; set; }
}
@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Giving;
public class OfferingGivingLineRequest
{
public int? MemberId { get; set; }
[Required] public int GivingCategoryId { get; set; }
[Range(0.01, 9999999)] public decimal Amount { get; set; }
[Required, MaxLength(20)] public string PaymentMethod { get; set; } = "Cash";
[MaxLength(50)] public string? CheckNumber { get; set; }
[MaxLength(100)] public string? ZelleReferenceCode { get; set; }
[MaxLength(100)] public string? PayPalTransactionId { get; set; }
public bool IsAnonymous { get; set; }
[MaxLength(500)] public string? Notes { get; set; }
}
@@ -0,0 +1,15 @@
namespace ROLAC.API.DTOs.Giving;
public class OfferingSessionDto
{
public int Id { get; set; }
public DateOnly SessionDate{ get; set; }
public string Status { get; set; } = "";
public decimal CashTotal { get; set; }
public decimal CheckTotal { get; set; }
public decimal SystemTotal { get; set; }
public decimal Difference { get; set; }
public string? Notes { get; set; }
public bool HasProof { get; set; }
public List<OfferingGivingLineDto> Givings { get; set; } = [];
}
@@ -0,0 +1,14 @@
namespace ROLAC.API.DTOs.Giving;
public class OfferingSessionListItemDto
{
public int Id { get; set; }
public string SessionDate { get; set; } = ""; // yyyy-MM-dd
public string Status { get; set; } = "";
public decimal CashTotal { get; set; }
public decimal CheckTotal { get; set; }
public decimal SystemTotal { get; set; }
public decimal Difference { get; set; }
public int LineCount { get; set; }
public bool HasProof { get; set; }
}
@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Giving;
// Minimal member fields the mobile offering-entry page collects when a giver
// isn't on file yet. Creates a Visitor; the rest of the profile can be filled
// in later from the admin Members page.
public class QuickAddMemberRequest
{
[Required, MaxLength(100)] public string FirstName_en { get; set; } = "";
[Required, MaxLength(100)] public string LastName_en { get; set; } = "";
[MaxLength(100)] public string? NickName { get; set; }
[MaxLength(100)] public string? FirstName_zh { get; set; }
[MaxLength(100)] public string? LastName_zh { get; set; }
[MaxLength(30)] public string? PhoneCell { get; set; }
}
@@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Giving;
public class UpdateGivingCategoryRequest
{
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
[MaxLength(200)] public string? Name_zh { get; set; }
[MaxLength(500)] public string? Description_en { get; set; }
[MaxLength(500)] public string? Description_zh { get; set; }
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
@@ -0,0 +1,3 @@
namespace ROLAC.API.DTOs.Giving;
public class UpdateGivingRequest : CreateGivingRequest { }
@@ -0,0 +1,50 @@
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.DTOs.Logging;
/// <summary>Row shape for the Audit Logs grid (no heavy Changes JSON).</summary>
public class AuditLogListItemDto
{
public long Id { get; set; }
public DateTimeOffset Timestamp { get; set; }
public string Level { get; set; } = null!;
public string Action { get; set; } = null!;
public string Category { get; set; } = null!;
public string? EntityName { get; set; }
public string? EntityId { get; set; }
public string? Summary { get; set; }
public string? UserId { get; set; }
public string? UserEmail { get; set; }
}
/// <summary>Full detail for the Audit Log dialog, including the before→after JSON.</summary>
public class AuditLogDetailDto : AuditLogListItemDto
{
public string? Changes { get; set; }
public string? IpAddress { get; set; }
public string? CorrelationId { get; set; }
}
/// <summary>Filters for the paged Audit Logs query.</summary>
public class AuditLogQuery
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
public DateTimeOffset? From { get; set; }
public DateTimeOffset? To { get; set; }
public string? Category { get; set; }
public string? Action { get; set; }
public string? EntityName { get; set; }
public string? EntityId { get; set; }
public string? UserId { get; set; }
public LogLevelEnum? MinLevel { get; set; }
public string? Search { get; set; }
}
/// <summary>Option lists for the Audit Logs filter UI.</summary>
public class AuditCatalogDto
{
public IReadOnlyList<string> Categories { get; set; } = [];
public IReadOnlyList<string> Actions { get; set; } = [];
public IReadOnlyList<string> Levels { get; set; } = [];
}
@@ -0,0 +1,43 @@
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.DTOs.Logging;
/// <summary>Row shape for the System Logs grid (no heavy exception text).</summary>
public class SystemLogListItemDto
{
public long Id { get; set; }
public DateTimeOffset Timestamp { get; set; }
public string Level { get; set; } = null!;
public string Category { get; set; } = null!;
public string Message { get; set; } = null!;
public bool HasException { get; set; }
public int? StatusCode { get; set; }
public string? RequestPath { get; set; }
public string? HttpMethod { get; set; }
public string? UserId { get; set; }
public string? CorrelationId { get; set; }
}
/// <summary>Full detail for the System Log dialog, including the stack trace.</summary>
public class SystemLogDetailDto : SystemLogListItemDto
{
public int? EventId { get; set; }
public string? Exception { get; set; }
public string? IpAddress { get; set; }
}
/// <summary>Filters for the paged System Logs query.</summary>
public class SystemLogQuery
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
public DateTimeOffset? From { get; set; }
public DateTimeOffset? To { get; set; }
/// <summary>Lower bound on severity (inclusive).</summary>
public LogLevelEnum? MinLevel { get; set; }
/// <summary>Exact severity match (takes precedence over MinLevel when set).</summary>
public LogLevelEnum? Level { get; set; }
public string? Search { get; set; }
public string? UserId { get; set; }
public string? CorrelationId { get; set; }
}
@@ -0,0 +1,10 @@
namespace ROLAC.API.DTOs.MealAttendance;
/// <summary>The current head-count for one Sunday, broadcast over SignalR.</summary>
public class AttendanceCountsDto
{
public string Date { get; set; } = ""; // yyyy-MM-dd (local)
public int Adult { get; set; }
public int Youth { get; set; }
public int Kid { get; set; }
}
@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Members;
public class CreateMemberRequest
{
[Required, MaxLength(100)] public string FirstName_en { get; set; } = "";
[Required, MaxLength(100)] public string LastName_en { get; set; } = "";
[MaxLength(100)] public string? NickName { get; set; }
[MaxLength(100)] public string? FirstName_zh { get; set; }
[MaxLength(100)] public string? LastName_zh { get; set; }
[MaxLength(10)] public string? Gender { get; set; }
public DateOnly? DateOfBirth { get; set; }
public DateOnly? BaptismDate { get; set; }
[MaxLength(200)] public string? BaptismChurch { get; set; }
[MaxLength(200), EmailAddress] public string? Email { get; set; }
[MaxLength(30)] public string? PhoneCell { get; set; }
[MaxLength(30)] public string? PhoneHome { get; set; }
[MaxLength(500)] public string? Address { get; set; }
[MaxLength(100)] public string? City { get; set; }
[MaxLength(50)] public string? State { get; set; }
[MaxLength(20)] public string? ZipCode { get; set; }
[MaxLength(100)] public string Country { get; set; } = "USA";
[MaxLength(20)] public string Status { get; set; } = "Member";
[MaxLength(10)] public string LanguagePreference { get; set; } = "en";
public DateOnly? JoinDate { get; set; }
public string? Notes { get; set; }
public int? FamilyUnitId { get; set; }
}
+21
View File
@@ -0,0 +1,21 @@
namespace ROLAC.API.DTOs.Members;
public class MemberDto : MemberListItemDto
{
public string? Gender { get; set; }
public DateOnly? DateOfBirth { get; set; }
public DateOnly? BaptismDate { get; set; }
public string? BaptismChurch { get; set; }
public string? PhoneHome { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? ZipCode { get; set; }
public string Country { get; set; } = "USA";
public string? PhotoBlobPath { get; set; }
public string LanguagePreference { get; set; } = "en";
public string? Notes { get; set; }
public int? FamilyUnitId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
@@ -0,0 +1,16 @@
namespace ROLAC.API.DTOs.Members;
public class MemberListItemDto
{
public int Id { get; set; }
public string FirstName_en { get; set; } = "";
public string LastName_en { get; set; } = "";
public string? NickName { get; set; }
public string? FirstName_zh { get; set; }
public string? LastName_zh { get; set; }
public string Status { get; set; } = "";
public string? Email { get; set; }
public string? PhoneCell { get; set; }
public DateOnly? JoinDate { get; set; }
public string? LinkedUserId { get; set; } // null = no user account
}
@@ -0,0 +1,2 @@
namespace ROLAC.API.DTOs.Members;
public class UpdateMemberRequest : CreateMemberRequest { }
@@ -0,0 +1,10 @@
namespace ROLAC.API.DTOs.Ministry;
public class MinistryDto
{
public int Id { get; set; }
public string Name_en { get; set; } = "";
public string? Name_zh { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; }
}
@@ -0,0 +1,28 @@
namespace ROLAC.API.DTOs.Notifications;
/// <summary>Top-level Line webhook payload (deserialized case-insensitively).</summary>
public sealed class LineWebhookPayload
{
public List<LineWebhookEvent>? Events { get; set; }
}
public sealed class LineWebhookEvent
{
public string? Type { get; set; } // follow | message | join | leave | ...
public string? ReplyToken { get; set; }
public LineWebhookSource? Source { get; set; }
public LineWebhookMessage? Message { get; set; }
}
public sealed class LineWebhookSource
{
public string? Type { get; set; } // user | group | room
public string? UserId { get; set; }
public string? GroupId { get; set; }
}
public sealed class LineWebhookMessage
{
public string? Type { get; set; } // text | image | ...
public string? Text { get; set; }
}
@@ -0,0 +1,7 @@
namespace ROLAC.API.DTOs.Notifications;
public sealed record UpdateGroupRequest(string? Name, bool IsActive);
public sealed record SendLineRequest(string Body, int[]? MemberIds, int[]? GroupIds);
public sealed record SendEmailRequest(string Subject, string HtmlBody, int[]? MemberIds, string[]? Addresses);
@@ -0,0 +1,53 @@
namespace ROLAC.API.DTOs.Permissions;
/// <summary>Effective action flags for one module (union across a user's roles).</summary>
public class ModuleActions
{
public bool Read { get; set; }
public bool Write { get; set; }
public bool Delete { get; set; }
public bool Approve { get; set; }
public bool Any => Read || Write || Delete || Approve;
}
/// <summary>One module's grant for a single role — used in the admin matrix and updates.</summary>
public class ModulePermissionDto
{
public string Module { get; set; } = null!;
public bool CanRead { get; set; }
public bool CanWrite { get; set; }
public bool CanDelete { get; set; }
public bool CanApprove { get; set; }
}
/// <summary>One role's full row in the admin matrix (every module, dense).</summary>
public class RolePermissionRow
{
public string RoleName { get; set; } = null!;
public string? Description { get; set; }
/// <summary>super_admin is shown read-only/full — it bypasses the matrix.</summary>
public bool IsSuperAdmin { get; set; }
public List<ModulePermissionDto> Modules { get; set; } = [];
}
/// <summary>GET /api/permissions — the whole matrix plus the catalog for grid headers.</summary>
public class PermissionMatrixDto
{
public IReadOnlyList<string> AllModules { get; set; } = [];
public IReadOnlyList<string> AllActions { get; set; } = [];
public List<RolePermissionRow> Roles { get; set; } = [];
}
/// <summary>GET /api/permissions/catalog — module + action names for building the UI.</summary>
public class PermissionCatalogDto
{
public IReadOnlyList<string> Modules { get; set; } = [];
public IReadOnlyList<string> Actions { get; set; } = [];
}
/// <summary>PUT /api/permissions/{roleName} — replaces a role's grants.</summary>
public class UpdateRolePermissionsRequest
{
public List<ModulePermissionDto> Modules { get; set; } = [];
}
+10
View File
@@ -0,0 +1,10 @@
namespace ROLAC.API.DTOs.Shared;
public class PagedResult<T>
{
public List<T> Items { get; set; } = [];
public int TotalCount { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
}
@@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Users;
public class CreateUserRequest
{
public int? MemberId { get; set; }
[Required, EmailAddress] public string Email { get; set; } = "";
[Required, MinLength(1)] public List<string> Roles { get; set; } = [];
public string LanguagePreference { get; set; } = "en";
}
@@ -0,0 +1,7 @@
namespace ROLAC.API.DTOs.Users;
public class CreateUserResult
{
public string UserId { get; set; } = "";
public string TempPassword { get; set; } = "";
}
@@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Users;
public class UpdateUserRequest
{
[Required, EmailAddress] public string Email { get; set; } = "";
[Required] public List<string> Roles { get; set; } = [];
public bool IsActive { get; set; }
public string LanguagePreference { get; set; } = "en";
}
+2
View File
@@ -0,0 +1,2 @@
namespace ROLAC.API.DTOs.Users;
public class UserDto : UserListItemDto { }
@@ -0,0 +1,14 @@
namespace ROLAC.API.DTOs.Users;
public class UserListItemDto
{
public string Id { get; set; } = "";
public string Email { get; set; } = "";
public int? MemberId { get; set; }
public string? MemberDisplayName { get; set; }
public List<string> Roles { get; set; } = [];
public bool IsActive { get; set; }
public string LanguagePreference { get; set; } = "en";
public DateTime? LastLoginAt { get; set; }
public DateTime CreatedAt { get; set; }
}
+385
View File
@@ -0,0 +1,385 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data.Logging;
using ROLAC.API.Entities;
using ROLAC.API.Entities.Notifications;
namespace ROLAC.API.Data;
public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<Member> Members => Set<Member>();
public DbSet<FamilyUnit> FamilyUnits => Set<FamilyUnit>();
public DbSet<GivingCategory> GivingCategories => Set<GivingCategory>();
public DbSet<OfferingSession> OfferingSessions => Set<OfferingSession>();
public DbSet<Giving> Givings => Set<Giving>();
public DbSet<Ministry> Ministries => Set<Ministry>();
public DbSet<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>();
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
public DbSet<Expense> Expenses => Set<Expense>();
public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>();
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
public DbSet<Check> Checks => Set<Check>();
public DbSet<CheckLine> CheckLines => Set<CheckLine>();
public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>();
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
public DbSet<MemberChannelBinding> MemberChannelBindings => Set<MemberChannelBinding>();
public DbSet<LineBindingCode> LineBindingCodes => Set<LineBindingCode>();
public DbSet<MessagingGroup> MessagingGroups => Set<MessagingGroup>();
public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// ── RefreshToken (unchanged) ────────────────────────────────────────
builder.Entity<RefreshToken>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.TokenHash).IsUnique();
entity.Property(e => e.TokenHash).HasMaxLength(64).IsRequired();
entity.Property(e => e.UserId).HasMaxLength(450).IsRequired();
entity.Property(e => e.DeviceInfo).HasMaxLength(200);
entity.Property(e => e.IpAddress).HasMaxLength(45);
entity.Property(e => e.ReplacedByHash).HasMaxLength(64);
entity.HasOne(e => e.User).WithMany(u => u.RefreshTokens)
.HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Cascade);
entity.Ignore(e => e.IsExpired);
entity.Ignore(e => e.IsRevoked);
entity.Ignore(e => e.IsActive);
});
// ── AppUser (unchanged + new unique index on MemberId) ──────────────
builder.Entity<AppUser>(entity =>
{
entity.Property(e => e.LanguagePreference).HasMaxLength(10).HasDefaultValue("en");
// Nullable unique: one member ↔ one user account, but member can have no account
entity.HasIndex(e => e.MemberId).IsUnique()
.HasFilter("\"MemberId\" IS NOT NULL");
});
// ── AppRole (unchanged) ─────────────────────────────────────────────
builder.Entity<AppRole>(entity =>
{
entity.Property(e => e.Description).HasMaxLength(500);
});
// ── RolePermission (configurable RBAC matrix) ───────────────────────
builder.Entity<RolePermission>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.RoleId).HasMaxLength(450).IsRequired();
entity.Property(e => e.Module).HasMaxLength(60).IsRequired();
// One row per (role, module).
entity.HasIndex(e => new { e.RoleId, e.Module }).IsUnique();
entity.HasOne(e => e.Role).WithMany()
.HasForeignKey(e => e.RoleId).OnDelete(DeleteBehavior.Cascade);
});
// ── FamilyUnit ──────────────────────────────────────────────────────
builder.Entity<FamilyUnit>(entity =>
{
entity.Property(e => e.FamilyName_en).HasMaxLength(200);
entity.Property(e => e.FamilyName_zh).HasMaxLength(200);
});
// ── Member ──────────────────────────────────────────────────────────
builder.Entity<Member>(entity =>
{
entity.HasQueryFilter(m => !m.IsDeleted);
entity.Property(e => e.FirstName_en).HasMaxLength(100).IsRequired();
entity.Property(e => e.LastName_en).HasMaxLength(100).IsRequired();
entity.Property(e => e.NickName).HasMaxLength(100);
entity.Property(e => e.FirstName_zh).HasMaxLength(100);
entity.Property(e => e.LastName_zh).HasMaxLength(100);
entity.Property(e => e.Gender).HasMaxLength(10);
entity.Property(e => e.BaptismChurch).HasMaxLength(200);
entity.Property(e => e.Email).HasMaxLength(200);
entity.Property(e => e.PhoneCell).HasMaxLength(30);
entity.Property(e => e.PhoneHome).HasMaxLength(30);
entity.Property(e => e.Address).HasMaxLength(500);
entity.Property(e => e.City).HasMaxLength(100);
entity.Property(e => e.State).HasMaxLength(50);
entity.Property(e => e.ZipCode).HasMaxLength(20);
entity.Property(e => e.Country).HasMaxLength(100).HasDefaultValue("USA");
entity.Property(e => e.PhotoBlobPath).HasMaxLength(500);
entity.Property(e => e.Status).HasMaxLength(20).HasDefaultValue("Member");
entity.Property(e => e.LanguagePreference).HasMaxLength(10).HasDefaultValue("en");
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.Property(e => e.DeletedBy).HasMaxLength(450);
entity.HasIndex(e => e.Status).HasFilter("\"IsDeleted\" = false");
entity.HasIndex(e => e.Email).HasFilter("\"Email\" IS NOT NULL");
entity.HasIndex(e => e.FamilyUnitId);
entity.HasOne(e => e.FamilyUnit).WithMany()
.HasForeignKey(e => e.FamilyUnitId).OnDelete(DeleteBehavior.SetNull);
});
// ── GivingCategory ───────────────────────────────────────────────────
builder.Entity<GivingCategory>(entity =>
{
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
entity.Property(e => e.Name_zh).HasMaxLength(200);
entity.Property(e => e.Description_en).HasMaxLength(500);
entity.Property(e => e.Description_zh).HasMaxLength(500);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
});
// ── OfferingSession ──────────────────────────────────────────────────
builder.Entity<OfferingSession>(entity =>
{
entity.Property(e => e.Status).HasMaxLength(20).HasDefaultValue("Draft");
entity.Property(e => e.CashTotal).HasColumnType("decimal(18,2)");
entity.Property(e => e.CheckTotal).HasColumnType("decimal(18,2)");
entity.Property(e => e.SystemTotal).HasColumnType("decimal(18,2)");
entity.Property(e => e.Difference).HasColumnType("decimal(18,2)");
entity.Property(e => e.SubmittedBy).HasMaxLength(450);
entity.Property(e => e.ReconciledBy).HasMaxLength(450);
entity.Property(e => e.ProofPdfPath).HasMaxLength(500);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => e.SessionDate).IsUnique();
});
// ── Giving ───────────────────────────────────────────────────────────
builder.Entity<Giving>(entity =>
{
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
entity.Property(e => e.PaymentMethod).HasMaxLength(20).IsRequired();
entity.Property(e => e.CheckNumber).HasMaxLength(50);
entity.Property(e => e.ZelleReferenceCode).HasMaxLength(100);
entity.Property(e => e.PayPalTransactionId).HasMaxLength(100);
entity.Property(e => e.Notes).HasMaxLength(500);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => new { e.MemberId, e.GivingDate });
entity.HasIndex(e => e.OfferingSessionId).HasFilter("\"OfferingSessionId\" IS NOT NULL");
entity.HasIndex(e => e.GivingDate);
entity.HasOne(e => e.GivingCategory).WithMany()
.HasForeignKey(e => e.GivingCategoryId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.OfferingSession).WithMany(s => s.Givings)
.HasForeignKey(e => e.OfferingSessionId).OnDelete(DeleteBehavior.Cascade);
});
// ── Ministry ─────────────────────────────────────────────────────────
builder.Entity<Ministry>(entity =>
{
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
entity.Property(e => e.Name_zh).HasMaxLength(200);
});
// ── ExpenseCategoryGroup ─────────────────────────────────────────────
builder.Entity<ExpenseCategoryGroup>(entity =>
{
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
entity.Property(e => e.Name_zh).HasMaxLength(200);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
});
// ── ExpenseSubCategory ───────────────────────────────────────────────
builder.Entity<ExpenseSubCategory>(entity =>
{
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
entity.Property(e => e.Name_zh).HasMaxLength(200);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasOne(e => e.Group).WithMany(g => g.SubCategories)
.HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict);
});
// ── Expense ──────────────────────────────────────────────────────────
builder.Entity<Expense>(entity =>
{
entity.HasQueryFilter(e => !e.IsDeleted);
entity.Property(e => e.Type).HasMaxLength(30).IsRequired();
entity.Property(e => e.Status).HasMaxLength(30).HasDefaultValue("Draft");
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
entity.Property(e => e.Description).HasMaxLength(500).IsRequired();
entity.Property(e => e.VendorName).HasMaxLength(200);
entity.Property(e => e.CheckNumber).HasMaxLength(50);
entity.Property(e => e.ReceiptBlobPath).HasMaxLength(500);
entity.Property(e => e.ReviewNotes).HasMaxLength(500);
entity.Property(e => e.SubmittedBy).HasMaxLength(450);
entity.Property(e => e.ReviewedBy).HasMaxLength(450);
entity.Property(e => e.PaidBy).HasMaxLength(450);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.Property(e => e.DeletedBy).HasMaxLength(450);
entity.HasIndex(e => e.MinistryId);
entity.HasIndex(e => e.Status).HasFilter("\"IsDeleted\" = false");
entity.HasIndex(e => e.ExpenseDate);
entity.HasOne(e => e.Ministry).WithMany()
.HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.CategoryGroup).WithMany()
.HasForeignKey(e => e.CategoryGroupId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.SubCategory).WithMany()
.HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
});
// ── ChurchProfile (singleton settings) ───────────────────────────────
builder.Entity<ChurchProfile>(entity =>
{
entity.Property(e => e.Name).HasMaxLength(200).IsRequired();
entity.Property(e => e.Address).HasMaxLength(500);
entity.Property(e => e.City).HasMaxLength(100);
entity.Property(e => e.State).HasMaxLength(50);
entity.Property(e => e.ZipCode).HasMaxLength(20);
entity.Property(e => e.BankName).HasMaxLength(200);
entity.Property(e => e.BankAccountNumber).HasMaxLength(50);
entity.Property(e => e.BankRoutingNumber).HasMaxLength(50);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
// Optimistic-concurrency token for safe check-number allocation.
entity.Property(e => e.xmin).IsRowVersion();
});
// ── Check (disbursement) ─────────────────────────────────────────────
builder.Entity<Check>(entity =>
{
entity.HasQueryFilter(c => !c.IsDeleted);
entity.Property(e => e.CheckNumber).HasMaxLength(50).IsRequired();
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
entity.Property(e => e.PayeeType).HasMaxLength(20).IsRequired();
entity.Property(e => e.PayeeName).HasMaxLength(200).IsRequired();
entity.Property(e => e.PayeeAddress).HasMaxLength(500);
entity.Property(e => e.PayeeCity).HasMaxLength(100);
entity.Property(e => e.PayeeState).HasMaxLength(50);
entity.Property(e => e.PayeeZip).HasMaxLength(20);
entity.Property(e => e.Status).HasMaxLength(20).HasDefaultValue("Issued");
entity.Property(e => e.Memo).HasMaxLength(500);
entity.Property(e => e.IssuedBy).HasMaxLength(450).IsRequired();
entity.Property(e => e.VoidReason).HasMaxLength(500);
entity.Property(e => e.VoidedBy).HasMaxLength(450);
entity.Property(e => e.ReceiptSignatureBlobPath).HasMaxLength(500);
entity.Property(e => e.ReceiptSignedName).HasMaxLength(200);
entity.Property(e => e.ReceiptCapturedBy).HasMaxLength(450);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.Property(e => e.DeletedBy).HasMaxLength(450);
// Unique check number among non-deleted rows.
entity.HasIndex(e => e.CheckNumber).IsUnique().HasFilter("\"IsDeleted\" = false");
entity.HasIndex(e => e.Status).HasFilter("\"IsDeleted\" = false");
entity.HasIndex(e => e.CheckDate);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
});
// ── CheckLine ────────────────────────────────────────────────────────
builder.Entity<CheckLine>(entity =>
{
// Mirror the parent Check's soft-delete filter (required relationship).
entity.HasQueryFilter(l => !l.Check!.IsDeleted);
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
entity.Property(e => e.Description).HasMaxLength(500).IsRequired();
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => e.CheckId);
entity.HasIndex(e => e.ExpenseId);
entity.HasOne(e => e.Check).WithMany(c => c.Lines)
.HasForeignKey(e => e.CheckId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Expense).WithMany()
.HasForeignKey(e => e.ExpenseId).OnDelete(DeleteBehavior.Restrict);
});
// ── MealAttendance (one shared row per Sunday) ───────────────────────
builder.Entity<MealAttendance>(entity =>
{
entity.Property(e => e.AdultCount).HasDefaultValue(0);
entity.Property(e => e.YouthCount).HasDefaultValue(0);
entity.Property(e => e.KidCount).HasDefaultValue(0);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => e.AttendanceDate).IsUnique();
});
// ── MonthlyStatement ─────────────────────────────────────────────────
builder.Entity<MonthlyStatement>(entity =>
{
entity.Property(e => e.OpeningBalance).HasColumnType("decimal(18,2)");
entity.Property(e => e.TotalGiving).HasColumnType("decimal(18,2)");
entity.Property(e => e.TotalOtherIncome).HasColumnType("decimal(18,2)");
entity.Property(e => e.TotalExpenses).HasColumnType("decimal(18,2)");
entity.Property(e => e.CalculatedClosingBalance).HasColumnType("decimal(18,2)");
entity.Property(e => e.BankStatementBalance).HasColumnType("decimal(18,2)");
entity.Property(e => e.Difference).HasColumnType("decimal(18,2)");
entity.Property(e => e.FinalizedBy).HasMaxLength(450);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => new { e.Year, e.Month }).IsUnique();
});
// ── Notifications (email + Line) ─────────────────────────────────────
builder.Entity<MemberChannelBinding>(entity =>
{
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
entity.Property(e => e.ExternalId).HasMaxLength(100).IsRequired();
entity.HasIndex(e => new { e.MemberId, e.Channel }).IsUnique();
entity.HasIndex(e => new { e.Channel, e.ExternalId }).IsUnique();
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<LineBindingCode>(entity =>
{
entity.Property(e => e.Code).HasMaxLength(20).IsRequired();
entity.HasIndex(e => e.Code);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<MessagingGroup>(entity =>
{
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
entity.Property(e => e.ExternalId).HasMaxLength(100).IsRequired();
entity.Property(e => e.Name).HasMaxLength(200);
entity.HasIndex(e => new { e.Channel, e.ExternalId }).IsUnique();
});
builder.Entity<NotificationLog>(entity =>
{
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
entity.Property(e => e.TargetType).HasMaxLength(20).IsRequired();
entity.Property(e => e.TargetExternalId).HasMaxLength(200).IsRequired();
entity.Property(e => e.Subject).HasMaxLength(300);
entity.Property(e => e.Status).HasMaxLength(20).IsRequired();
entity.Property(e => e.SentByUserId).HasMaxLength(450).IsRequired();
entity.HasIndex(e => e.SentAt);
entity.HasIndex(e => e.Channel);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.MessagingGroup).WithMany()
.HasForeignKey(e => e.MessagingGroupId).OnDelete(DeleteBehavior.SetNull);
});
// ── SystemLog / AuditLog (append-only) ───────────────────────────────
// Mapped here for SCHEMA only — there are deliberately no DbSets on this
// context, so business code can't write logs through the audited context.
// Runtime reads/writes go through the dedicated LogDbContext. Including
// them in the model lets the single startup migration create the tables.
LogModelConfiguration.Configure(builder);
}
}

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