gets a single “Joined the Church” event from dateJoinedChurch ?? createdAt.
- A curated allowlist of
AuditLogentries filtered bytargetId = memberId(MEMBER_ACTIVATED/DEACTIVATED,WORKER_PROMOTED/REINSTATED/REVOKED,WORKER_TRAINEE_DEMOTED,WORKER_TRAINEE_STATUS_CHANGED,CLERGY_ASSIGNED/TITLE_CHANGED/REMOVED). This is deliberately not every audit action for the member — noisy ones likeMEMBER_UPDATED,MEMBER_LOGIN, or the genericWORKER_PROFILE_UPDATED(fires on any profile field edit, carries no clean before/after) are excluded so the timeline reads as milestones, not a raw change log.WORKER_PROMOTED/REINSTATED/WORKER_TRAINEE_STATUS_CHANGED’smetadata.departmentIdis resolved to a name via one batchedDepartmentlookup (not per-event).WORKER_TRAINEE_STATUS_CHANGED’smetadata.isTraineepicks the title:true→ “Started Training”,false→ “Completed Training” (a trainee promoted to a full worker — distinct fromWORKER_TRAINEE_DEMOTED, which is a trainee losing worker status entirely and reverting to plainMEMBER). - Each
SundaySchoolAttendancerow counted towardsundaySchoolVisitCount(status = 'PRESENT', pre-conversion viafirst_timer_idand post-conversion viamember_id) also becomes its ownSUNDAY_SCHOOL_VISITevent, title “Attended Sunday School”,descriptionthe class name,occurredAtthe session’ssessionDate— so the count is never a bare number with no dated entries backing it; every visit it includes is individually visible inevents. - Outreach journey — if the member was an evangelism
Convert(converts.member_id = memberId, orconverts.first_timer_id= the member’s first-timer), the timeline opens with:MET_ON_OUTREACH(“Met on Outreach”, outreach title + team, at the convert’screatedAt);CONVERT_STATUS_CHANGEDfrom that convert’sCONVERT_STATUS_UPDATEDaudit rows (looked up bytargetId = convert.id;SAVED→ “Saved”,UNDERGOING_DISCIPLESHIP→ “Started Discipleship”,UNSAVEDskipped); and oneEVANGELISM_FOLLOW_UPevent (“Followed Up After Outreach”, “N contacts by …”, at the last evangelism contact before Follow-Up took over).Convert/ConvertFollowUpLogare registered read-only inMemberModule(importingEvangelismModulewould be circular). None of these count toward the visit counts.
isTraineeNow is a live status read straight off member.workerProfile?.isTrainee (not derived from events) — a
current-state badge for “is this person in training right now,” separate from the dated TRAINEE_STATUS_CHANGED
history entries. false for a non-worker.
childrenChurchDropOffs is a separate rollup, not part of serviceVisitCount/sundaySchoolVisitCount — see
§Children Church Module for what it counts and why it’s kept apart from the member’s own visit counts.
- Known gap:
DEPARTMENT_LEAD_ASSIGNED/REMOVEDare not yet included — those audit entries target the department row (not the member), soAuditLogService.findAll’stargetIdfilter can’t find them without ametadataquery capability it doesn’t have today. A worker whose promotion predates audit logging (legacy data, bulk imports) falls back toWorkerProfile.createdAtfor a “Became a Worker” event so they aren’t silently missing from the timeline.
serviceVisitCount and sundaySchoolVisitCount are two independent rollups, computed alongside events rather
than derived from them, of “how many times has the church actually seen this person” — split because regular
service attendance and Sunday School are different programs, not one combined headcount (previously a single
visitCount field silently summed both, which read as one inflated, unexplained number — e.g. a member with 1
service visit and 2 Sunday School sessions showed “3 visits” with no way to tell what made it up).
serviceVisitCount sums: the FIRST_VISIT/REPEAT_VISIT event count already in events (from the first-timer
pipeline, carried across the first-timer → member lifecycle instead of resetting to zero on conversion — the same
figure FollowUpService.getFirstTimerDetail computes for a still-unconverted first-timer, see below); and regular
Attendance rows with status IN ('PRESENT', 'LATE'), matching the status-filter convention used everywhere else
attendance is counted (AttendanceService). sundaySchoolVisitCount sums SundaySchoolAttendance rows linked via
first_timer_id (pre-conversion, queried only if a FirstTimer record exists) and via member_id
(post-conversion), both filtered to status = 'PRESENT' — queried separately because a Sunday School attendance
row is never re-linked from one FK to the other when a first-timer converts. ABSENT/EXCUSED Sunday School rows
and ON_LEAVE/ABSENT regular-attendance rows don’t count as a visit; ATTENDED_ONLINE isn’t included either,
consistent with these being physical-attendance figures. (Fixed 2026-09-23 — the two Sunday School counts
originally had no status filter at all, so ABSENT/EXCUSED rows inflated the combined visitCount; then split
into serviceVisitCount/sundaySchoolVisitCount the same day so the breakdown is visible, not just the total.)
Profile photo: POST /members/me/photo (multipart, field photo) uploads/replaces the caller’s own photo via
CloudinaryService (folder profile-pictures); DELETE /members/me/photo removes it. Both JwtAuthGuard only —
self-service, no admin permission required. DELETE /members/:id/photo (AdminGuard + MEMBERS_WRITE) lets an
admin clear another member’s photo for moderation. All three return the updated MemberDto. Audit-logged as
MEMBER_PHOTO_UPDATED / MEMBER_PHOTO_REMOVED, with metadata: { self: true|false } distinguishing a member’s
own action from an admin’s.
Member Bulk Import
Lets an admin create many members at once from a spreadsheet, via a preview-then-commit flow so validation errors
can be reviewed before anything is written. Controller: MemberImportController, all routes AdminGuard +
MEMBERS_WRITE.
Routes prefix: /members/bulk-import
| Method | Path | Description |
|---|---|---|
| GET | /members/bulk-import/template |
Streams a .xlsx template with the expected columns (see below) |
| POST | /members/bulk-import/preview |
Multipart upload, field name file, 5 MB cap (LimitedFileInterceptor). Parses and validates every row, persists a MemberImportJob + MemberImportRow[], returns { ...job, rows } |
| GET | /members/bulk-import/:jobId |
Refetch a previously-previewed job and its rows |
| PATCH | /members/bulk-import/:jobId/rows/:rowId |
Save partial draft field corrections and revalidate the whole job; returns { ...job, rows } |
| POST | /members/bulk-import/:jobId/commit |
Creates a Member (+ WorkerProfile if the row’s department column was filled) for every row with zero validation errors; generates a random temp password per member and emails it via the welcome-member template; returns { createdCount, failedRows } |
Commit is batched, not per-row. commitImport resolves duplicate-email and department-name lookups for the
entire batch in 2 queries up front (not one of each per row), then inserts every still-eligible row’s Member
(+ WorkerProfile, if applicable) in a single transaction. This means a row can still independently fail
pre-validation (email taken since preview, unknown department) and land in failedRows exactly as before, but a
row that passes pre-validation and is included in the transaction is no longer isolated from the others — a genuine
DB-level failure during the bulk insert (e.g. a race-condition constraint violation) fails the whole commit rather
than just that one row, unlike the old per-row-transaction implementation. In practice this only matters for the
rare case a pre-validated row fails for a reason pre-validation couldn’t catch.
Template columns: First Name*, Last Name*, Email*, Phone Number, Gender (MALE/FEMALE), Birth Day (1-31), Birth Month (1-12), Birth Year, Marital Status (SINGLE/MARRIED/DIVORCED/WIDOWED), Year Born Again, Year Baptized, Baptized With Holy Ghost (TRUE/FALSE), Date Joined Church (YYYY-MM-DD), Department (optional — creates the member as a Worker), Profession, Year Joined Workforce.
Excel cell parsing: headers and data fields use ExcelJS cell.text, so hyperlink and rich-text cells are
read as their displayed text instead of becoming [object Object]. Cached formula results are supported;
the API does not evaluate spreadsheet formulas. Email display text is trimmed and lowercased before validation
and the existing batched duplicate checks. A hyperlink destination is not substituted for invalid display text.
For jobs previewed before this parsing fix, correct affected fields directly during review or upload the
spreadsheet again. The original structured cell contents are not retained in previously parsed row data.
Review corrections: an admin with MEMBERS_WRITE can PATCH { "data": { "email": "corrected@example.com", "department": "Media" } } to an existing row in a READY_FOR_REVIEW job. Only template field keys and scalar/null
values are accepted; omitted fields are retained, while empty strings or null clear a field. Text is trimmed,
emails lowercased, enums uppercased, and numeric/boolean draft fields normalized before validation. Invalid draft
values stay in the review with errors instead of creating a member; invalid numeric text is preserved rather than
serialized as null. Preview and edits share batched existing-email/department lookups and SignupDto validation.
Every row is revalidated after an edit so duplicate errors can be introduced or cleared on other rows, and
validRows is recomputed from that result. Edits never create members, worker profiles, or welcome emails.
Committed jobs and rows outside the selected job are rejected. Edits and commits take the same pessimistic job
row lock within the tenant request transaction, serializing competing saves/imports. The existing job primary key
and import-row job-ID index cover these lookups; no schema/index migration is needed.
Successful corrections emit MEMBER_IMPORT_ROW_UPDATED with the linked member as actor and metadata containing
the administrator ID, spreadsheet row number, changed field names, and valid-row count (not field values).
The admin review page exposes inline save/cancel editing, keeps draft changes after a failed save, and disables
upload/confirm actions until the active edit is saved or cancelled.
Validation (at preview time, one pass over every row):
- Each row is validated against
SignupDto’s rules (required fields, formats). - A phone number is normalized to E.164 using the region from
CURRENCY_LOCALE(defaulten-NG); invalid numbers are flagged on preview rather than saved in local/national format. - Duplicate email within the file is flagged, pointing at the earlier row number.
- Email already existing in the DB is flagged.
- A filled
departmentcolumn is looked up case-insensitively; an unknown department name is flagged as an error (Unknown department: "...") and the row is excluded from commit. job.validRows= rows with zero errors; only those are eligible for commit.
Commit behavior: re-checks each valid row’s email uniqueness and department lookup (guards against a race between
preview and commit); on a per-row failure the row is marked FAILED with commitError set and processing continues
with the remaining rows rather than aborting the whole job. A job can only be committed once — re-committing an
already-COMMITTED job returns 400 Bad Request.
Admin Module
Manages the admin RBAC system used by the admin web portal. This module is @Global() — its providers (AdminGuard,
AdminService, AdminRoleService) are available across the entire app without explicit module imports.
AdminRole routes (/admin/roles):
GET /admin/roles—ADMIN_READ— list all rolesGET /admin/roles/:id—ADMIN_READ— get role by IDPOST /admin/roles—ADMIN_WRITE— create rolePATCH /admin/roles/:id—ADMIN_WRITE— update roleDELETE /admin/roles/:id—ADMIN_WRITE— delete role (blocked if active admins use it)
Admin user routes (/admin/users):
GET /admin/users—ADMIN_READ— list all admin usersGET /admin/users/me— any admin — own admin profile. Uses a dedicatedAdminService.getMyProfile()rather thanfindById()/theAdminGuard-preloaded admin — it’s the only placemember.spouseis loaded, so an admin’s own profile page can show their spouse. Kept offAdminGuard’s preload (which runs on every guarded request) and offfindById()(used for viewing other admins) so that extra join only happens on this one self-service call.PUT /admin/users/me/favourite-pages— any admin — saves the pages they pinned to the dashboard’s Quick Access (Admin.favouritePages), so pins follow them across devices. The “most used” pages that fill the remaining Quick Access slots are counted per browser (localStorage) and never sent to the API. The portal filters both by the admin’s permissions and enabled modules before showing them.GET /admin/users/:id—ADMIN_READ— get admin by IDPOST /admin/users—ADMIN_WRITE— grant admin access to a memberPATCH /admin/users/:id—ADMIN_WRITE— change admin role or active status; an admin cannot modify their own record (403)POST /admin/users/:id/revoke—ADMIN_WRITE— soft-revoke admin access (isActive = false)
Security notes:
- Admin user read endpoints (
GET /admin/users,GET /admin/users/me,GET /admin/users/:id) strippasswordanddeviceIdfrom the joined Member before returning — these fields are never returned to API clients. - Role-change audit entries capture the previous and new role name in addition to the changed field list.
Predefined role seed (migration): A one-time migration (SeedPredefinedAdminRoles) seeds 9 ready-to-use roles
covering the typical org structure. The migration is idempotent — it uses ON CONFLICT ("name") DO NOTHING so
re-running it on a database that already has these roles is safe.
| Role name | Typical use |
|---|---|
| Super Admin | All permissions |
| General Admin | Most read/write permissions excluding admin RBAC |
| Member Coordinator | Members read/write |
| Content Manager | Announcements write |
| Welfare & Pastoral | Notes read/write, members read |
| Children Church Coordinator | Children church read/write |
| Sunday School Coordinator | Sunday school read/write |
| Attendance Monitor | Attendance read |
| Leave Approver | Leave read/write |
Default seed: On application bootstrap, if DEFAULT_ADMIN_EMAIL is set and no admin exists with that email, the
system creates:
- A
Memberwithrole = MEMBERandchangedPassword = false - A
SuperAdminAdminRolecarrying all permissions - An
Adminrecord linking the two
Orphaned 'Super Admin' (with a space) role — cleaned up: TenantSchemaGenesis
(src/migrations/tenant/1790726400000-TenantSchemaGenesis.ts, the schema-genesis migration every new tenant still
runs) seeds a legacy 'Super Admin' role from before AdminRoleService’s 'SuperAdmin' (no space) naming
convention existed — immutable history, can’t be edited. Since it runs before seedTenantAdmin() (application
code, not a migration), every newly-provisioned tenant ended up with two full-permission roles: the orphaned,
never-assigned 'Super Admin', and the real, actively-used 'SuperAdmin' (the one later permission-grant
migrations like GrantSocialMediaPermissions target, and the one the real admin is actually assigned to).
seedTenantAdmin() now deletes the orphaned row for new tenants (safe unconditionally at that point — admins
is guaranteed empty, so nothing can reference it via admins.admin_role_id’s ON DELETE RESTRICT FK); a new
migration (1792566000000-RemoveOrphanedSuperAdminSpaceRole.ts, tenant schema) cleans up the rows already sitting
in existing tenants, guarded by the same “no admin references it” check so a genuine edge case is left untouched
rather than failing the migration.
Church Settings Module
Lets an admin turn optional feature modules on/off per-installation without a deploy — the mechanism that keeps the
platform usable by congregations that don’t run every ministry this codebase supports. Backed by ChurchSetting
(key unique, value: jsonb = { enabled: boolean, displayName?: string }), read through ChurchSettingsService
with a short-TTL cache (cacheService) so isEnabled() checks on every request don’t hit Postgres each time.
KNOWN_MODULES (src/church-settings/constants/known-modules.constant.ts) is the fixed list of togglable
modules, each with a required: boolean. required: true modules (departments, service_programme) can never be
disabled — PATCH /admin/settings/:key returns 400 if attempted. Everything else defaults to required: false:
incident_report, asset_management, evangelism, follow_up, pastor_feedback, prayer, sunday_school,
children_church, facility_rental, tithe, classes, announcements. A module with no row in the database is
treated as enabled (absent = on) — a fresh install has everything available until an admin opts out.
displayName override: an admin can rename a module’s label (e.g. “Pastor Feedback” → “Elders’ Feedback”) via
the same PATCH /admin/settings/:key body without touching enabled. ChurchSettingsService.upsert() merges
rather than overwrites — passing { enabled } alone preserves whatever displayName was previously set (a toggle
flip must never silently blank out a custom label). Both frontends fall back to the module’s default label when
displayName is unset.
Enforcement (ModuleEnabledGuard + @RequiresModule(key)): mirrors the existing AdminGuard +
@RequiresPermission idiom — @RequiresModule('evangelism') sets metadata via Reflector, and ModuleEnabledGuard
(added into the controller’s existing @UseGuards([...]) array, not a separate decorator call) reads it and calls
isEnabled(), throwing 403 if the module is off. Applied at the controller level across every optional module’s
admin and member-facing controllers, so a disabled module is fully unreachable via the API, not just hidden in the
UI.
GET /modules/state (JwtAuthGuard, any authenticated member/worker/admin) is the one shared source of truth
for “is module X on,” returning { key, enabled, displayName }[] for every known module (displayName falls back
to the module’s default label). Both discuva-admin’s sidebar and discuva-member mobile’s Explore/Ministry/Leadership
tiles read from this single endpoint (useModuleState() hook, near-identical implementation in both frontends)
rather than each frontend independently guessing module state — the same duplication risk already seen once with
discuva-admin’s hardcoded permission-group list (see Admin Module’s AdminPermissionGroups note below).
AdminPermissionGroups visibility tied to module state: each AdminPermissionGroup (see AdminPermission enum
reference) optionally carries a moduleKey. discuva-admin’s role-permission picker (app/admin-management/page.tsx)
and the read-only permission display (app/profile/page.tsx) both filter PERMISSION_GROUPS/AdminPermissionGroups
through isModuleEnabled(group.moduleKey) before rendering — an admin is never offered permissions for a feature
that’s disabled for their church. Core, non-toggleable groups (Members, Events & Venues, Departments, Attendance,
Finance, Administration, etc.) carry no moduleKey and are always shown.
Routes prefix: /admin/settings (admin CRUD), /modules/state (shared read endpoint, all authenticated roles)
Reminder Settings Module
Lets a tenant admin control the timing (and on/off state) of 8 reminder-email categories, per-installation, without a
deploy — previously every value below was either a hardcoded literal or a single global env var, invisible and
unconfigurable to anyone but whoever edits deploy config. Backed by the same ChurchSetting entity/table as the
Church Settings module above (key unique, value: jsonb), under a disjoint key namespace (`reminder:${key}`)
— reuses the proven pattern with zero new migration, but through its own service/controller
(ReminderSettingsService/ReminderSettingsController), since the value shape ({ enabled, thresholds }) and
whitelist (ReminderSettingKey) differ from the module-toggle shape and shouldn’t be forced through
ChurchSettingsService.
Not the same thing as EmailCategory: src/utility/email-provider/email-category.enum.ts gates every
category of email the system sends, at a coarser granularity than reminder settings — e.g.
EmailCategory.ASSET_ALERTS is shared by all 4 asset schedulers (maintenance, warranty, vehicle-expiry,
overdue-checkout), EmailCategory.FINANCE_ALERTS by both pledge and budget alerts. It used to be global-only
(env-flag booleans in EmailQueueService.isCategoryEnabled) — it now also has a per-tenant override
(EmailCategorySettingsService, see “Email Category Settings Module” below), but ReminderSettingKey remains a
separate, finer-grained, per-tenant enum layered on top of both — if either the env flag or the tenant’s
EmailCategory setting is off, that still suppresses sends regardless of any tenant-level ReminderSettingKey
setting (EmailQueueService.queueEmail checks its own two gates before a job is ever enqueued, upstream of anything
the reminder schedulers decide).
KNOWN_REMINDER_SETTINGS (src/reminder-settings/constant/known-reminder-settings.constant.ts) — the 6 keys,
each { label, unit, defaultThresholds }. thresholds is a list of signed integers whose meaning depends on the
key’s unit: for the date-based ones it’s day-offsets relative to a due/expiry date (positive = before, 0 = on
the day, negative = after/overdue); for budget_alert it’s percent-of-budget-used thresholds. Defaults exactly
match each scheduler’s prior hardcoded/env-default value, so shipping this was behavior-neutral until a tenant
actually changes one:
ReminderSettingKey |
Unit | Default thresholds | Scheduler |
|---|---|---|---|
pledge_reminder |
days relative to due date | [7, 0, -3] |
PledgeReminderScheduler |
budget_alert |
% of budget used | [80, 100] |
BudgetAlertScheduler |
follow_up_stale |
days since last activity | [7] |
FollowUpScheduler.notifyInactiveTasks |
asset_maintenance |
days before due | [7, 3, 1, 0] |
MaintenanceReminderScheduler |
asset_warranty |
days before expiry | [30, 14, 7, 1] |
WarrantyAlertScheduler |
vehicle_expiry |
days before expiry | [30, 14, 7, 1] |
VehicleExpiryAlertScheduler |
assignment_due |
days relative to due date | [3, 1, 0] |
AssignmentReminderScheduler |
class_session |
hours before session | [24, 1] |
ClassSessionReminderScheduler |
smsEnabled — email-always, SMS-optional (Training Classes only): assignment_due and class_session are the
only two reminder keys with a tenant-configurable smsEnabled: boolean (default false) on top of the usual
enabled/thresholds shape (ReminderSettingValue/ReminderSettingResponseDto/UpdateReminderSettingDto all
carry it; stored on the same flexible ChurchSetting.value jsonb column, no migration needed). Every other
reminder key is email-only and unaffected. The email always sends when a threshold matches (to member.email or
guest.email — both exist for every Training Classes enrollee now, guest or member); SMS is an additional send,
skipped entirely unless smsEnabled is on and a phone number is on file for that specific enrollee (a guest’s
phone is optional). This mirrors the guest contact model chosen for Classes generally: email-first, phone/SMS
opt-in — see the Guest entity section above.
AssignmentReminderScheduler(src/classes/scheduler/assignment-reminder.scheduler.ts): for each publishedAssignmentwith adueDate, findsIN_PROGRESSenrollees of its class (member or guest) with no matching submission yet (ClassEnrollmentLEFT JOINAssignmentSubmissionon eithermember_idorclass_enrollment_id,WHERE submission IS NULL), computesdiffDaysvs. today, and — if it matches a configured threshold — emails (assignment-due-remindertemplate,EmailCategory.ASSIGNMENT_REMINDER) and optionally SMS-nudges (generic, non-personalized text, no link) every qualifying enrollee. Cache-deduped per(assignmentId, enrolleeId, diffDays)so a reminder never double-sends within the same day.ClassSessionReminderScheduler(src/classes/scheduler/class-session-reminder.scheduler.ts): same structural pattern, keyed perChurchClasswithnextSessionAtset (not per-assignment) —diffHours, notdiffDays, matching this key’s hours-based unit. EmailsIN_PROGRESSenrollees (class-session-remindertemplate,EmailCategory.CLASS_SESSION_REMINDER) with the class name, session time, andmeetingLink(conditionally rendered in the template if set); SMS text includes the meeting link when present. Separate scheduler/key fromassignment_duesince they’re conceptually different triggers (per-assignment vs. per-class) and admins may want different thresholds for each (e.g. “1 hour before” for a meeting vs. “3 days before” for an assignment deadline).- Calendar invite: every send also attaches a generated
.icsfile (class-session.ics, built viabuildIcsEvent— see “Calendar invites (.ics)” below) so the session can be added to the recipient’s calendar directly from the email, the same treatmentservice-slot-assigned/service-slot-reminderalready give a service assignment.ChurchClasshas no explicit session-duration field, so the invite defaults to a 1-hour block starting atnextSessionAt. The invite’sUIDis keyed on`${churchClass.id}-${nextSessionAt.getTime()}@classes-session`— built once per (class, session) and reused for every recipient of that run — so repeated reminders (24h, 1h) for the same unchanged session update the one calendar entry the recipient already has, while reschedulingnextSessionAtproduces a new entry rather than silently mutating the old one.meetingLink, when set, is used as both the invite’sLOCATIONand its description text.
- Calendar invite: every send also attaches a generated
Both use the same forEachActiveTenant + Redis-lock + per-tenant getConfig() pattern as every other reminder
scheduler (see “Runtime read” below) and are registered in ClassesModule (not a separate module) — they’re
Training Classes-specific, not general-purpose. AssignmentReminderScheduler runs @Cron(EVERY_DAY_AT_8AM),
matching the date-based thresholds; ClassSessionReminderScheduler runs @Cron(EVERY_HOUR), matching its
hours-based thresholds — an hourly-granularity trigger needs an hourly check to land on the right hour.
Explicitly excluded from tenant control (unreachable by any tenant-facing route, unchanged hardcoded/global
behavior): overdue-checkout alerts (asset accountability — a deliberate product decision, not a tenant
preference), prayer reminders (dual 2-day-ahead/day-of logic doesn’t fit the list-of-offsets shape), and Pastor
Feedback’s weekly reminder (its timing is the @Cron('0 9 * * 1') schedule itself — tenant-configurable cron
cadence would need dynamic SchedulerRegistry registration, a materially different change than a settings value).
Runtime read (getConfig(key)): each of the 8 schedulers calls this inside its forEachActiveTenant(...)
callback — not once at construction — since cron jobs have no ambient tenant context outside that loop, and
CacheService’s tenant-scoped cache keys rely on the CLS store forEachActiveTenant populates per iteration. If
enabled is false, the scheduler returns before any email is queued for that tenant that run.
Per-scheduler notes:
BudgetAlertScheduler: unlike the date-based schedulers’thresholds.includes(diffDays)check, budget alerts usethresholds.some(t => utilizationPct >= t && !alreadySent(t)), sorted descending so only the highest newly-crossed threshold fires per run (matches the original 80/100 behavior of never double-alerting in one pass). Dedup moved from the old fixedalert80SentAt/alert100SentAtcolumns to a genericBudget.alertsSent: number[]jsonb column (arbitrary threshold count needs a matching data structure) — seeAddBudgetAlertsSentColumnmigration. The old columns are left in place, unused, rather than dropped, to avoid irreversible data loss on this pre-existing table.MaintenanceReminderScheduler: same generic-column treatment —MaintenanceSchedule.notifiedThresholds: number[]replaces the 4 fixednotifiedNDaysAtcolumns. The overdue branch (daysUntilDue < 0) is untouched — still an unconditional daily nag vialastOverdueNotifiedAt, not part of the configurable threshold list (deliberate:asset_maintenance’s unit is “days before due,” it was never meant to cover overdue).WarrantyAlertScheduler/VehicleExpiryAlertScheduler: same treatment onAsset—warrantyNotifiedThresholds,insuranceNotifiedThresholds,roadworthinessNotifiedThresholds(3 new jsonb columns) replace 12 old fixed columns combined. SeeAddAssetExpiryNotifiedThresholdsmigration.FollowUpScheduler.notifyInactiveTasks:FOLLOW_UP_STALE_DAYSenv var removed entirely (superseded); if multiple thresholds are ever configured, the minimum is used as the staleness cutoff (a single scalar concept, using the same list shape as the others for UI/DTO consistency, not because multiple values are meaningful here).PledgeReminderScheduler:getNextDueDate’s recurrence-search window, previously hardcoded to look 8 days ahead (matched the old fixed7threshold), now derives its lookahead fromMath.max(...thresholds)— a tenant configuring a threshold further out than 7 days would otherwise silently never match, since the search would stop before reaching it.
Routes: GET/PATCH /admin/reminder-settings, GET/PATCH /admin/reminder-settings/:key — same AdminGuard +
AdminPermission.ADMIN_WRITE-on-write pattern as /admin/settings above.
Frontend: discuva-admin’s /notification-settings page (own layout.tsx wrapping <Shell> — every new
top-level route needs one, there is no global Shell in root layout.tsx) — one row per setting: an enabled/disabled
toggle plus an editable numeric-chip list (add/remove) for thresholds. The assignment_due/class_session rows
additionally show an SMS toggle bound to smsEnabled — every other row hides it, since only those two keys carry
the field.
Email Category Settings Module
Lets a tenant admin turn off any of the 15 EmailCategory values for their own church — the gap that made every
category effectively mandatory in practice: the only pre-existing suppression mechanism
(EmailQueueService.isCategoryEnabled) was gated behind process-wide EMAIL_<CATEGORY>_ENABLED env vars, so
disabling one meant disabling it for every tenant simultaneously (a single NestJS process serves all tenants).
Same ChurchSetting-backed pattern as Reminder Settings above (own key namespace, `email_category:${category}`,
zero new migration), own service/controller (EmailCategorySettingsService/EmailCategorySettingsController) since
the value shape ({ enabled }) and whitelist (EmailCategory, already defined in src/utility/email-provider/) are
unrelated to the module-toggle and reminder-threshold shapes.
Two independent gates, either can suppress: EmailQueueService.isCategoryEnabled(category) checks the env var
first (unchanged, still the platform-wide kill switch — rarely touched, requires a redeploy) and only calls
EmailCategorySettingsService.isEnabled(category) if the env var didn’t already suppress it, so a globally-disabled
category never even reaches the tenant-level DB/cache lookup.
Module wiring note: EmailCategorySettingsModule is @Global() but deliberately does not import
UtilityModule (also @Global()) — EmailQueueService lives inside UtilityModule and needs to inject
EmailCategorySettingsService, so an explicit cross-import would be circular. Since both modules are global, this
isn’t needed: UtilityModule’s own exports (CacheService, AuditLogService) resolve into
EmailCategorySettingsService’s constructor regardless of whether its module lists UtilityModule in imports.
KNOWN_EMAIL_CATEGORIES (src/email-category-settings/constant/known-email-categories.constant.ts) — a
{ label, description } per category, all defaulting to enabled (no DB row = on, same fail-open default every
other settings mechanism in this codebase uses).
Fixed alongside: EmailCategory.SERVICE_PROGRAMME_ASSIGNMENT was referenced in
EmailQueueService’s flag map but had no corresponding EMAIL_SERVICE_PROGRAMME_ASSIGNMENT_ENABLED entry in
env.validation.ts/.env.example — harmless while true (undefined !== false), but meant the var could never
actually be set without Joi’s forbidNonWhitelisted rejecting it. Now registered like the other 14.
Routes: GET/PATCH /admin/email-category-settings, GET/PATCH /admin/email-category-settings/:category — same
AdminGuard + AdminPermission.ADMIN_WRITE-on-write pattern as /admin/reminder-settings.
Separate Email and Push switches: the stored value is { enabled, pushEnabled } — enabled gates email,
pushEnabled gates push. PATCH accepts either or both (at least one). Responses add hasPush (whether any push in
PUSH_CATALOGUE belongs to the category) and pushEnabled (false when hasPush is false). Rows saved before the
split have no pushEnabled; it falls back to enabled, so a church that had switched a whole category off keeps its
push off. isPushEnabled(category) is cached under push-category-settings:{category} and cleared on update.
Delivery mode: the value also carries pushFirst (default false), and responses add pushFirst plus a derived
mode: EMAIL_AND_PUSH (both on), PUSH_FIRST (both on + pushFirst), PUSH_ONLY, EMAIL_ONLY, OFF. PATCH
accepts { mode } (what the admin UI sends — it is expanded into the three flags) or the raw flags as before; a push
mode for a category with no push notifications is a 400. isPushFirst(category) (cached under
push-first-category-settings:{category}) is true only while email and push are both on. Push first is applied in
NotificationDispatchService.notifyMember: when the same call carries an email and a push, an email address is
dropped if its member (email.recipientMemberId, or email.recipientMemberIds parallel to a multi-address to) is in
the push’s memberIds and has a push subscription (PushNotificationService.membersWithSubscription, one query on
push_subscriptions, unique per member). Emails with no paired push are never suppressed. Flows that pass recipient
ids and so honour Push first: service-programme assignments and reminders (individual and department), Sunday School
Q&A, and event reminders; everything else sends email as before.
Frontend: the “Email Categories” section on discuva-admin’s /notification-settings page has one row per category
with a delivery-mode select (Email + Push / Push first / Push only / Email only / Off; Email / Off where there is no
push) and a one-line explanation for the less obvious modes.
Push catalogue (src/notification-catalogue/push-catalogue.ts): PUSH_CATALOGUE holds every push notification’s
default wording, keyed by PushNotificationKey — category, admin-facing label/description, title/body with
{{placeholders}}, default url and a sample value per placeholder. Senders pass
{ key, vars, idempotencyKey, url? } to PushNotificationService.dispatchToMemberIds/dispatchToWorkerProfileIds or
NotificationDispatchService.notifyMember({ push }). PushNotificationService checks the category’s Push switch,
fills placeholders with plain token replacement (never a template engine, so future church-edited wording can’t run
code) and clips to 60/150 characters. Announcements are the one exception: an admin writes them, so they pass
{ title, body, url, idempotencyKey } and aren’t gated by a category switch. Before this, prayer, pastor-feedback and
announcement pushes ignored the category switches entirely.
Custom push wording (notification customization, Phase 1): churches on a plan with
PlanFeature.NOTIFICATION_CUSTOMIZATION (notification_customization, added to every Pro variant by root migration
AddNotificationCustomizationToProPlans) can replace a catalogue push’s title and/or message.
- Storage: tenant table
notification_template_overrides(NotificationTemplateOverride, tenant migrationCreateNotificationTemplateOverrides) —channel(PUSH;EMAILreserved for Phase 2),template_key, nullabletitle/body(null = use the default for that field),updated_by_id(members,SET NULL); unique(channel, template_key). - Sending:
PushNotificationServiceasksNotificationTemplateService.resolvePushTemplate(key)for the wording. It returns the church’s override only while the church’s plan includes the feature, so a downgraded church falls back to the defaults without losing its saved text. Overrides are cached per church undernotification-overrides:push(5 min), cleared on every save or reset. - Validation on save: text is made plain (HTML tags and line breaks removed), can’t be empty, is limited to 60
(title) / 150 (message) characters, and may only use that notification’s own placeholders — anything else is
rejected with the list of valid ones. Saving wording identical to the default stores nothing. Audited as
NOTIFICATION_TEMPLATE_UPDATED/NOTIFICATION_TEMPLATE_RESET. - Endpoints (
admin/notification-templates,AdminGuard; there is no separate church-settings permission, so the sameadmin:read/admin:writepair as Notification Settings):GET push(every plan — returns{ customizationAvailable, items }so the portal can show defaults with an upgrade prompt), and on plans with the feature:PUT push/:key{ title, body },DELETE push/:key(reset to default),POST push/:key/test({ title?, body? }draft; sends it filled with sample values to the calling admin’s own device, or returns{ sent: false, reason: 'NO_DEVICE' }if they haven’t turned on notifications). NOTIFICATION_CUSTOMIZATIONis labelled “Notification Customization” inPlatformCapabilityServiceso the platform Plans page lists it.
Custom email wording (notification customization, Phase 2): same plan feature and permissions as push. Eight
emails are customizable so far — welcome-member, happy-birthday, service-reminder,
first-timer-membership-invite, tithe-proof-confirmed, pledge-contribution-confirmed, class-session-reminder,
assignment-due-reminder.
- Catalogue:
EMAIL_CATALOGUE(src/notification-catalogue/email-catalogue.ts), keyed by the existing template name, holds each email’s default wording (reproducing the old files word for word, except the service reminder now says “Open the app” rather than naming the product), placeholders with sample values, atoVars(data)mapping from the sender’s template data to friendly placeholder names, alockedNotefor admins, andsampleDatafor previews.categoryisnullfor the welcome email (it always sends). - Layout: these emails no longer have standalone HTML files. They render as
templates/layouts/base.html(shared head, styles, logo header, sign-off and footer) wrappingtemplates/content/<key>.html, which holds only the optional heading, the editablemessage, the locked block (credentials, buttons, amounts, dates, links) and the editableclosing. - Editable fields:
subject,heading,message(rich),closing(rich),signoff,signature. Plain fields are stripped to text; rich fields are cleaned withSanitizationService.sanitizeForEmail(passed in from the controller so the template service, loaded byEmailQueueService, doesn’t pull in jsdom). Every field may only use the email’s own placeholders plus{{church_name}}; subject and message are required. Only fields that differ from the default are stored, innotification_template_overrides.content(jsonb, tenant migrationAddEmailContentToNotificationTemplateOverrides), cached per church undernotification-overrides:email. - Sending:
EmailQueueService.queueEmailWithTemplate*detects catalogue template names and renders them withNotificationTemplateService.resolveEmailWording()(church edits while the plan allows, else defaults) — including the subject, which replaces the caller’s. Placeholders are filled by plain token replacement with HTML-escaped values; church wording is inserted as data and never compiled by Handlebars. Every other email still loads its own file unchanged. - Class reminders now also pass
statusTitle(e.g. “Starts in 1 Hour”) so the default subjects stay identical.
All emails customizable, plus change history (notification customization, Phase 3):
- Every member/worker email is now in the catalogue (63 in total). The 55 added in this phase live in
EXTRA_EMAIL_CATALOGUE(src/notification-catalogue/email-catalogue.extra.ts), merged intoEMAIL_CATALOGUE, which is now keyed by plain template name (EmailTemplateKeystill names the original eight). Their old standalone files were split intotemplates/content/<key>.htmlplus, where the email had its own styles (OTP boxes, asset tables, banners),templates/content/<key>.css, which the layout injects into<head>via{{{ extra_styles }}}. The visible body text of every converted email matches the old file; the differences are the standard footer (the “Need help?” line now appears wherever a support email is set), three simplified hidden preview lines (asset maintenance, programme assignments, slot assigned), the child-pickup email now using the standard layout, and the product name dropped from three defaults (“your account” / “the app” in device-reset confirmation, login security alert and online attendance request). - Still fixed (not in the catalogue): platform emails (
founder-welcome,platform-admin-welcome,tenant-approval-needed,tenant-welcome) and three admin-only reports with bespoke layouts (finance-budget-alert,report-export,service-session-report). - Subjects: the added emails default to
{{default_subject}}, which is filled with the subject the sending code passes (so dynamic subjects keep working); churches may keep it, surround it with their own words, or replace it. If a subject renders empty, the sender’s subject (or the email’s label, in previews) is used. - Optional parts: the sign-off paragraph is hidden when both
signoffandsignatureare empty.messageis required only when the default has one — some emails’ default message is empty because all of their text is in the locked block. - List grouping: emails without a category show under their catalogue
group(Classes,Finance requests,Giving,Workforce) or “Account emails”. - Recipient details in every message: besides each message’s own placeholders (and
{{church_name}}), every email and push accepts{{first_name}},{{last_name}},{{full_name}},{{email}},{{phone}},{{title}}(Mr; Mrs if married/widowed; Miss if single; Ms otherwise; blank without a gender),{{church_title}}(Brother/Sister) and{{department}}(a worker’s primary department) —RECIPIENT_PLACEHOLDERSinsrc/notification-catalogue/recipient.ts. Values the sender passes win; recipient details only fill gaps.NotificationRecipientServicelooks the member up (by the singletoaddress for email, case-insensitive; by member id for push, one query per dispatch) only when the wording uses a recipient token the sender didn’t supply, so default wording costs no extra query. A multi-address email, a non-member address or a failed lookup leaves those tokens blank rather than failing the send. Pushes whose wording uses them are rendered per member. Previews use sample values. Test sends (POST push|email/:key/test) use the calling admin’s own linked-member details for these placeholders — blank where unknown, as a real send would be, falling back to samples only if the member can’t be found — and, in emails, for the name shown in locked parts; message-specific values (amounts, dates, class names) stay samples. The follow-up task email’s first-timer contact placeholders are{{first_timer_email}}/{{first_timer_phone}}so{{email}}/{{phone}}always mean the recipient. - Reads on the send path are schema-qualified: most senders queue emails/pushes fire-and-forget, so the work
often runs after the request’s (or scheduler’s) tenant transaction has closed, when a tenant repository silently
falls back to the
publicschema. Wording overrides, recipient details, the per-category Email/Push switches (church_settings) and push subscriptions/worker lookups are therefore read withqueryTenant()(src/tenant/utility/query-tenant.ts), which prefixes the CLSschemaName(validated) and returns no rows when there is no church context. Wording lookups also fall back to the defaults on any error, so a customization problem can never stop an email or push from sending. (First seen in production asrelation "notification_template_overrides" does not existonPOST /tithes/me/statement/send.) - Change history: tenant table
notification_template_versions(NotificationTemplateVersion, tenant migrationCreateNotificationTemplateVersions) —channel,template_key,action(SAVED/RESET/RESTORED),content(jsonb snapshot of the full wording in effect after the change:{ title, body }for push, all six email fields for email),created_by_id(members,SET NULL); indexed on(channel, template_key, created_at). A row is written on every save, reset and restore; only the newest 20 per message are kept. Restoring re-saves the snapshot through the normal save path, so it is re-validated (a version using a placeholder that no longer exists is refused) and is itself recorded asRESTORED. - Endpoints:
GET push/:key/historyandGET email/:key/history(admin:read, any plan) return[{ id, action, content, changedBy, createdAt }], newest first;POST push/:key/history/:versionId/restoreandPOST email/:key/history/:versionId/restore(admin:write+ plan feature) return the updated template view.
Event Module
Manages events and service slots. Events can be single or repeating (daily/weekly/monthly, with an end date or
ongoing). At least one serviceSlot is required at creation — each slot carries an optional configId pointing to an
EventConfig. A repeating event is stored as an EventSeries (see Event series below) whose slot blueprint is
stamped onto every generated occurrence; updating the config later propagates to all check-ins that reference it.
CreateEventDto takes no eventDate/endDate/startTime/endTime fields — all four are always derived from the
supplied serviceSlots (eventDate/endDate = earliest startTime/latest endTime, UTC-date-truncated so the
result doesn’t depend on server timezone; startTime/endTime are the same two instants kept at full precision).
This applies on both create (including each recurring occurrence, computed from its own offset-shifted slots) and
update (whenever serviceSlots is replaced). There is no longer a “manual” date range independent of the slots —
previously a caller could set an event date range that didn’t match its slot times (e.g. editing a slot’s time left
the event’s dates stale), which this removes by construction.
eventDate/endDate stay date-only because several queries filter on a calendar day (e.g. “events today or later”).
startTime/endTime exist alongside them specifically because a date-only comparison can’t tell whether an event
that ends later today has actually finished yet — getUpcomingEvents and findEventsReadyForAbsenceMarking both
filter on endTime, not endDate, for this reason, and both frontends’ “Past” badge logic does the same.
Routes prefix: /events, /event-config
Each slot can have multiple reminder schedules via sub-resource /events/slots/:slotId/reminders (admin-only). See EventReminder model.
Admin frontend UX (discuva-admin, app/events/page.tsx, components/events/event-form.tsx, utils/event-schedule.ts): the form asks for the date once and each service as a start time (<input type="time">) plus a length (30m/1h/1h30/2h chips, or separate hours and minutes fields — minutes over 59 carry into hours), with the end time shown read-only. “Add another service” starts the new row when the previous one ends and copies its config, venue and format (chainRow; “First Service” → “Second Service”). rowsToSlots converts the rows to the unchanged serviceSlots[].startTime/endTime ISO payload in the browser’s local time, and slotsToSchedule loads an existing event back into date + rows. scheduleIssues mirrors the API’s checks inline — overlap with the previous service, a missing config, and (on create) a time that has passed — and the Create button stays disabled with the first problem shown under it. A Runs over several days switch under the date (off by default; on automatically when any service has a dayOffset) adds a Day 1 / Day 2… picker to each service, shown with real dates; turning it off puts every service back on the event date. Rarely used settings (description, online attendance, per-service format and venue overrides) sit together in a More options card (per-service settings grouped by service inside it), opened automatically when any is already set; while closed it lists what’s set as chips. A Save as a service type card explains the benefit (offered under Schedule Event next time) and saves inline. Smart defaults: the config is pre-selected when there’s only one, otherwise the last one used in this browser (utils/last-used.ts, localStorage wrapped in try/catch); a blank event name becomes “{Weekday} Service” and blank service names become the event name (single service) or “First/Second… Service”. Repeats offers Doesn’t repeat / Weekly / Every 2 weeks / Monthly / Custom, with Ends Never (sends recurrence.ongoing: true) or On date. Schedule Event opens a chooser of saved service types (see below) or Blank; picking a type pre-fills the form with the next date on its saved weekday.
Reusing a past event (components/events/reuse-event-dialog.tsx, utils/event-reuse.ts): “Reuse” opens a small dialog instead of the full form. It suggests the next date on the same weekday as the original’s first slot (nextSameWeekday — today if it’s that weekday and the start time hasn’t passed, otherwise the coming one), with one-tap “Week after” / “Today” (when today’s times are still ahead) and a date picker. shiftSlotsToDate moves every slot to the chosen date keeping its local time and the day gaps between slots (day arithmetic in UTC, so DST doesn’t shift times); configs, venues and format overrides are kept. “Create event” posts straight to POST /events as a one-off (a regular service should use Repeats instead); “Edit details…” opens the usual form pre-filled with the shifted slots. A date whose times have already passed can’t be submitted — the same rule the API enforces.
The API independently rejects creation if any slot starts before the current instant; this rule applies to POST /events and does not prevent editing an existing event (the form’s past-time check likewise runs only on create).
Reminder dispatch (cron */15 * * * *): Queries EventReminder rows where enabled = true, lastSentAt IS NULL, fireAt <= now, and slot.startTime > now. The filter runs entirely in SQL — fireAt is pre-computed at reminder creation (and recalculated if intervalPreset is updated). When a slot is deleted or recreated (e.g., event update), its reminders are cascade-deleted. On create, fireAt = slot.startTime − preset_minutes. On update with a new intervalPreset, fireAt is recalculated from the existing slot’s startTime.
Service slot ordering: EventService.getAll(), getById(), and getUpcomingEvents() all explicitly order the serviceSlots relation by startTime ASC (query-builder .addOrderBy('serviceSlots.startTime', 'ASC') for getAll; TypeORM’s relation order option for the other two, e.g. order: { serviceSlots: { startTime: 'ASC' } }). Without this, a joined one-to-many relation has no guaranteed order — First/Second Service could come back in either order depending on DB/join internals, which showed up as the admin portal’s event list not consistently showing slots in the order they begin.
Editing an event’s slots is blocked once the event has any recorded history. EventService.update()'s slot-replacement path (slotRepository.delete + recreate) previously ran unconditionally — ServiceProgramme/ServiceSession/session-slots/action-log all cascade off ServiceSlot, and Attendance.serviceSlot is ON DELETE SET NULL, so replacing the slots on an event that had already run would silently destroy its programme/session history and detach any recorded attendance from the slot it was for. hasRecordedHistory(eventId) now checks (via two raw dataSource queries, matching attachMyAttendance’s existing pattern rather than adding new repository injections) whether any attendances row or any service_sessions row (joined through service_programmes/service_slots) exists for the event; if either does, the whole PATCH is rejected with a 400 before touching any slot. Cosmetic fields (name/description) remain editable regardless — only serviceSlots replacement is gated. This is a one-way door: once an event has history, its schedule can never be edited again, only replaced by creating a new event (a deliberate, safer default over a more capable diff-based in-place slot update, which was considered and explicitly deferred).
Slot blueprint (src/event/types/slot-blueprint.ts): series and service types store services as times of day, not instants: { name, startTime: "HH:mm", durationMinutes, dayOffset, configId?, venueOverrideId?, formatOverride? } (SlotBlueprintDto: HH:mm regex, duration 1–1440, dayOffset 0–13). blueprintToSlotDtos(blueprint, date, tz) builds each slot on date + dayOffset with fromZonedTime in the church timezone, so a 09:00 service stays 09:00 across DST changes; slotDtosToBlueprint is the inverse (via formatInTimeZone). The timezone is the tenant’s timezone column, falling back to env TIMEZONE (ChurchTimezoneService, 10-minute in-memory cache per tenant).
Event series (EventSeriesService, event_series table): every POST /events with isRecurring: true now creates a series row (pattern, interval, startDate, endDate — null when recurrence.ongoing — the slot blueprint, autoProgramme, generatedThrough, isActive) and its occurrences are ordinary events rows with recurringEventId = series.id and seriesOccurrenceDate (the church-local date they stand for; unique per series via UQ_events_series_occurrence). A fixed end date must be within a year of the start; an ongoing series has none.
- Generation:
generate(series, tz, now, until?)walks occurrence dates (k × interval days/weeks; monthly via calendar months) aftergeneratedThroughup tomin(until ?? today + 56 days, endDate), skips dates whose first service has already started or that already exist, builds each viaEventService.buildOccurrence, and advancesgeneratedThrough. Because it never revisits dates at or beforegeneratedThrough, cancelling one date (DELETE /events/:id) sticks — it is not recreated. - Top-up (
EventSeriesScheduler):@Cron('0 2 * * *'), Redis locklock:event-series-top-up(1800 s),forEachActiveTenant; tops every active series up to 8 weeks ahead in that tenant’s timezone, one series’ failure logged without stopping the rest. It only loads series that can still gain a date (generated_throughis null or before the horizon, and beforeend_datefor a fixed series), served by the partial indexIDX_event_series_active, so finished series aren’t reloaded every night. - Editing (
PATCH /events/series/:id): updates the series fields/blueprint, then applies them to upcoming occurrences withseriesOccurrenceDate >= effectiveFromthat haven’t started and have no recorded history (hasRecordedHistory). If the service names are unchanged, each occurrence’sservice_slotsrows are updated in place (times, config, venue, format) so programmes, sessions and reminders stay attached. Unsent reminders on a moved service get theirfireAtrecalculated (EventService.retimeReminders, saved through the repository soSchedulerGateSubscriberwakes theevent-remindersjob); without this they would fire at the old time. If services were added or removed, those occurrences must be recreated — the first call returns 409{ code: "SERIES_RECREATE_REQUIRED", affected }and the client resends withconfirmRecreate: true. A name-only change also renames occurrences with history. Returns{ updated, recreated, skippedWithHistory }; auditEVENT_SERIES_UPDATED. - Stopping (
POST /events/series/:id/stop{ from }): setsendDate = from − 1 day, deactivates the series and removes upcoming occurrences on/afterfromwithout history; returns{ removed }; auditEVENT_SERIES_STOPPED.DELETE /events/recurring/:idalso deactivates the series. - Older recurring groups created before series existed have no series row; they keep working as plain events but can’t be edited as a series (
GET /events/series/:id→ 404).
Programmes prepared on creation: after a single event is created, and after each series occurrence is generated, EventService.prepareProgrammes calls ServiceProgrammeService.createDraftsFromTemplates(slots): each new service slot whose name matches a programme template’s serviceSlotName (trimmed, case-insensitive) gets a DRAFT programme with the template’s items, including department assignments, and createdByAdmin = null. No notifications are sent at creation — assignees see it in My Assignments and the usual day-before reminder still goes. Slots that already have a programme are skipped; failures are logged and never block event creation. Opt out per event with autoProgramme: false on POST /events, or per series with autoProgramme on the series. The admin form lists the matching services with an opt-out checkbox.
Service types (EventTemplateService, event_templates table): a saved setup — name (unique, case-insensitive; 409 on clash), description, onlineAttendanceEnabled, slotBlueprint, defaultRecurrence ({ recurrencePattern, recurrenceInterval, ongoing, weekday? } or null; weekday 0 = Sunday lets the admin pre-fill the next matching date) and autoProgramme. Reference data, so GET /events/templates returns the full list ordered by name. Audit EVENT_TEMPLATE_SAVED / EVENT_TEMPLATE_DELETED. The admin “Save as service type…” link on the event form updates the type with the same name if one exists.
deleteEvent/deleteFutureRecurring/getAll’s upcoming filter now use precise startTime/endTime, not the date-only eventDate/endDate — same class of fix as findEventsReadyForAbsenceMarking/getUpcomingEvents above, just not originally carried through to these three call sites. Concretely: deleteEvent previously compared eventDate (start date) to today, so a same-day event that had already fully ended hours ago was still deletable; now blocks on endTime < now. deleteFutureRecurring previously selected occurrences via eventDate >= today, so an already-started (or already-ended) same-day occurrence still counted as “future”; now uses startTime >= now, and — previously entirely missing — also filters attendanceMarked = false, matching deleteEvent’s own guard (this bulk path bypasses deleteEvent entirely, so it needs the same safety check independently). getAll’s upcoming filter now matches getUpcomingEvents’ own semantics (endTime >= now) instead of showing an already-ended-today event as still upcoming.
Recurring event occurrence spacing is computed in UTC explicitly, not the runtime’s local calendar. advanceDate() used date-fns’ addDays/addWeeks/addMonths, which advance via the process’s local timezone; the resulting date-to-date millisecond delta is then applied directly to each generated occurrence’s absolute slot startTime/endTime. If the runtime’s local timezone ever observed DST, a transition between occurrences would skew every subsequent occurrence’s actual time by up to an hour — the same category of server-timezone dependence truncateToUtcDate already guards against elsewhere in this service. Rewritten to advance via setUTCDate/setUTCMonth instead, so occurrence spacing is exact regardless of server TZ.
Venue Module
Manages named venue records referenced by event configs and individual service slots. Venues decouple location data from event creation — create a venue once, reference it by ID in any config or slot.
Routes prefix: /venues
ADMIN: create, update, delete
Any authenticated user: list (full, unpaginated — admin-controlled reference data), get by ID, find nearby venues by radius
latitude and longitude must be updated together on PATCH — providing only one is rejected by validation, preventing a venue’s stored point from being silently detached from reality mid-edit.
Attendance Module
Check-in window logic:
- Window opens:
slot.startTime + workerCheckinStartOffsetSeconds(workers) or+ memberCheckinStartOffsetSeconds( members) - Window closes:
slot.startTime + checkinStopOffsetSeconds(same for all) - Workers are LATE if they check in after
slot.startTime + workerLateOffsetSeconds - Members are always PRESENT if within the window
Location is required from members too when the tenant enforces distance checking, not just workers.
Workers on an IN_PERSON slot have always had a hard requirement (checkin() throws if !dto.location), unconditional
regardless of the enforce-distance setting. Members previously had no equivalent — location is @IsOptional() on
CheckInDto, so a member could simply omit it and skip distance validation entirely (validateLocation() only
runs if (dto.location && cfg.venue)), independent of whether the tenant had enforcement turned on. Now, for an
IN_PERSON slot, a member omitting location while enforceDistance() is true gets a BadRequestException
(“Your location is required to check in for this service”); when enforcement is off, location stays fully optional
for members (matches validateLocation()'s own behavior — it never rejects on distance when unenforced, so
requiring location unconditionally would add friction with no effect).
EventConfigService.validateOffsets cross-checks checkinStopOffsetSeconds against memberCheckinStartOffsetSeconds
too, not just workerLateOffsetSeconds. Without this a config could pass every existing check yet still leave
members with an impossible window — e.g. workerCheckinStart=-600, workerLate=-30, checkinStop=-15 all validate
fine against each other, but memberCheckinStart=-10 means members’ window would only open at -10s, after
check-in had already closed at -15s.
Per-event, not per-slot, check-in dedup is intentional, not a bug. Attendance has @Unique(['member', 'event'])
— a worker rostered for multiple slots of the same event (e.g. serving both First and Second Service) checks in
once for the whole event, not once per slot. This is a deliberate compromise: requiring a separate check-in per
slot for every service someone serves in the same event was judged worse than the alternative. Do not “fix” this
by moving to a per-slot unique constraint without revisiting the product decision first.
markAbsentees() defers all followUpQueue.add() calls until the entire batch has been written without error.
The whole per-tenant cron run is one Postgres transaction (this.txHost.tx, entered by forEachActiveTenant), but
followUpQueue.add() is a Redis/Bull side effect that isn’t part of that transaction and can’t roll back with it.
Previously each event’s Bull job was enqueued immediately after its own DB writes, inside the same loop — so if a
later event in the batch threw (e.g. a race with a concurrent check-in hitting the unique constraint above),
the whole transaction rolled back, but jobs already enqueued for earlier events in that same run did not, leaving
POST_EVENT_JOBs scheduled for events whose absence rows no longer existed. Now every event’s {event} is
collected during the loop and only enqueued in a second pass after the loop completes successfully — if anything
throws mid-batch, nothing has been enqueued for any event in that run, matching the transaction’s own all-or-nothing
semantics.
AttendanceService.getBatchApprovedLeave compares leave dates against event.eventDate as a plain 'YYYY-MM-DD'
string, not the raw Date object. event.eventDate is a date column, hydrated by the pg driver as a JS Date
at local-timezone midnight; passing that Date directly as a query parameter risks the driver re-serializing it
(e.g. via UTC toISOString()) before Postgres compares it against request_leave.date_from/date_to (also date
columns), which can shift the effective calendar date by a day depending on server timezone. Formatting it as a
plain date string first (via local getters, which round-trip the same y/m/d the pg driver used to construct the
Date in the first place, regardless of what the server’s actual local timezone is) sidesteps that reinterpretation
entirely — Postgres parses the string as a DATE literal with no timezone involved.
AttendanceService.confirmOnlineAttendance no longer locks a member out mid-window if onlineAttendanceEnabled
is toggled off after the confirm emails already went out. Previously checked event.onlineAttendanceEnabled
unconditionally first — an admin disabling the toggle after onlineNotificationSentAt (but before the window
closes) meant every member clicking their already-sent confirmation link got “Online attendance is not enabled for
this event” instead of the window simply running its course. Now checks onlineNotificationSentAt first: if it’s
set, the window was already opened for this event and stays valid regardless of the toggle’s current state; the
toggle is only checked (for the clearer “not enabled” vs. “window has not opened yet” message) when the window was
never opened at all. Also now compares against this.dateService.now() instead of a bare new Date(), matching
the rest of the module’s convention.
Event audience (events.audience, audience_group_id; also on event_series and event_templates). Who an
event is for is set on the event, never on a service slot: attendance is one row per member per event, so a
per-slot audience could not be tracked or marked separately. Different audiences (a workers’ meeting, a teens class)
are separate events. EVERYONE (default) behaves as before. WORKERS / GROUP (a groups row, via
audienceGroupId) narrow, through src/event/utility/event-audience.ts:
- Already checked in — a second check-in for the same event (any service) returns 400
{ code: "ALREADY_CHECKED_IN", slotId, slotName, checkinTime }(409 with the same code on a concurrent duplicate); the member app treats it as checked in, shows the disabled “You’re checked in” state, and refreshes events when it comes back into view. Bug fixed (2026-10-06):EventService.attachMyAttendance(the source ofcheckedIn/myCheckin) and several other tenant reads —hasRecordedHistory, the contact-list audience checks,confirmOnlineAttendance’s event lookup, attendance rank/stats/history and follow-up report/pipeline queries — used the injectedDataSource, which runs on a pooled connection with the defaultpublicsearch_path, not the request’s tenant transaction. They silently readpublic.*(socheckedInwas always false whilecheckin(), using a tenant repository, correctly refused a second check-in). They now usethis.txHost.tx. Rule: tenant-table reads must go throughtxHost.tx, a tenant repository, orqueryTenant(...)— never a bareDataSource. - Check-in —
AttendanceService.assertInAudiencereturns 403 “{event} is for workers only.” / “…is for {group} only.”. - Admin / front-desk marking —
adminMarkAttendanceapplies the same check before creating a new record (correcting an existing record is still allowed). - Streaks, leaderboard, rank and attendance % are computed only from a person’s own attendance rows, so with no row ever created for people outside the audience (above), an event for others can’t break their streak or change their score.
- Absence marking —
MemberService.getMembersNotCheckedInForEvent/getWorkersNotCheckedInForEventtake the event and applyscopeToAudience, so people outside the audience are never marked absent (previously every event marked the whole congregation). - Member app visibility —
GET /eventsandGET /events/:idfrom the member surface filter witheventVisibleToViewerSql(404 for an event not meant for the caller); the admin surface sees everything. - Slot reminders — recipients are intersected with the audience, and the in-app announcement is narrowed
(
WORKERS_ONLY, orGROUPwith the event’s group).resolveAudiencevalidates the group (400 if missing) and drops a group id for any other audience. Series pass the audience to each generated occurrence, and a series edit applies it to upcoming dates. AGROUPevent whose group is later deleted (ON DELETE SET NULL) falls back to everyone. Admin: “Who is it for?” (Everyone / Workers only / A contact list —groupsare labelled Contact Lists in the admin UI) at the top of the event form, carried by service types; list and detail show “Workers only” / “{group} only”.
Check-in close rule (EventConfig.checkinCloseMode, per-slot checkinCloseModeOverride). SERVICE_END keeps
check-in open for members and workers until each service’s own endTime — no offset to tune, so one config fits services
of any length. AFTER_START closes at startTime + checkinStopOffsetSeconds (or the slot override), capped at the
service’s end. Resolution: slot.checkinCloseModeOverride ?? config.checkinCloseMode
(EventService.resolveSlotConfig); applied by AttendanceService.checkinCloseTime and mirrored by discuva-member’s
resolveSlot().checkinWindowEnd. Neither mode changes attendance status — workers are still LATE from
workerLateOffsetSeconds, and absences are still marked after the event’s endTime. Existing configs default to
AFTER_START (migration AddCheckinCloseMode); new configs from the admin form default to SERVICE_END.
EventConfigService.validateOffsets skips the stop-offset ordering checks for SERVICE_END. Admin: Event Config has a
“Check-In Closes” choice with live examples; the event form’s More options has a per-service “Check-in closes”
(Config default / When the service ends / A set time after it starts).
The config’s stop offset is a ceiling. Check-in closes at
min(startTime + checkinStopOffsetSeconds, endTime) (AttendanceService.validateCheckinWindow, mirrored by
discuva-member’s resolveSlot().checkinWindowEnd). One config therefore fits services of any length: a 60-minute
stop offset on a 30-minute service simply closes check-in when that service ends. Earlier, EventService.buildSlotFromDto
rejected any slot shorter than its config’s offset (“would leave check-in open past its own end time”), which forced
admins to create per-length configs and could also fail a series’ nightly generation; that rejection now applies
only to a per-service checkinStopOverride longer than that service (an explicit, contradictory value). Tenant
migration CapCheckinStopOffsetAtSlotEnd had already clamped existing over-long overrides; with the runtime cap
it is no longer needed for correctness but is left in place (migrations are immutable).
Attendance Distance Check Setting — two layers, per-tenant override on top of a platform-admin default.
Previously ENFORCE_DISTANCE_CHECK was a single global env var — one on/off switch shared by every tenant, no
per-church control, requiring a redeploy to change. Now two layers, same shape as the upload-limit settings above:
- Platform-wide default (
PlatformSettingKey.ENFORCE_DISTANCE_CHECK_DEFAULT,PlatformSettingsService. getEnforceDistanceCheckDefault()) — platform-admin-editable live via/platform/settings, same page as the upload limits and subscription grace period. Stored as0/1(type: 'boolean'in the response — the settings page renders a toggle instead of a number input for this one, discuva-platform’sbilling-settings/page.tsx). Unlike every otherPlatformSetting, its “no row yet” fallback is not a hardcodedKNOWN_PLATFORM_SETTINGSdefault —PlatformSettingsService.resolveDefault()reads the liveENFORCE_DISTANCE_CHECKenv var instead, specifically so shipping this didn’t silently flip behavior for any environment that already had that env var set to something other than the old default. - Per-tenant override (
AttendanceSettingsService,src/attendance/service/attendance-settings.service.ts) —ChurchSetting-backed (key: 'attendance:enforce_distance_check'), same pattern asReminderSettingsService/EmailCategorySettingsServicebut living insideAttendanceModulerather than its own top-level module, since it’s a single key, not a family.getConfig()returns{enabled, isPlatformDefault}so the admin UI can show whether a church is following the platform default or has set its own value.AttendanceService.enforceDistance()callsAttendanceSettingsService.isEnabled()(cached, tenant-scoped) on every check-in with a location — no longer a value read once at boot into a constructor field.
Routes: GET/PATCH attendances/settings/distance-check (AdminGuard, ATTENDANCE_READ/ATTENDANCE_WRITE) —
admin read/write. GET attendances/me/distance-check (JwtAuthGuard only) — member-readable mirror of the same
AttendanceSettingsService.getConfig(), added so the member app can skip its own client-side distance pre-check
when a tenant has enforcement turned off, instead of always blocking regardless of the setting. Not sensitive
data, safe for any authenticated member to read.
Frontend (admin): discuva-admin’s Event Config page (DistanceCheckBanner) — sits alongside the
per-EventConfig “Allowed Distance (meters)” field it works together with: the radius is per-config, this toggle
is tenant-wide.
Frontend (member): discuva-member’s useEvents hook fetches GET attendances/me/distance-check once and
gates its existing client-side pre-check (computed before the POST /attendances/checkin call, using the
device’s geolocation and the slot’s resolved venue/allowedDistanceInMeters) behind distanceCheckEnforced. A
distance-blocked check-in — whether caught client-side or returned by the server — gets a visually distinct “Too
Far Away” treatment (not the generic “Check-in Failed” look) via the code: 'TOO_FAR' field described below.
Too-far check-in — structured exception, not just a message string. AttendanceService.validateLocation()
throws BadRequestException({ message: 'You are too far from the venue to check in.', code: 'TOO_FAR', distanceMeters, allowedDistanceInMeters }) — extra keys beyond message are spread into the JSON response body
by the global exception filter (HttpExceptionFilter, same mechanism PlanGuard’s code: 'PLAN_UPGRADE_REQUIRED'
already uses). distanceMeters is the member’s actual computed distance (rounded), included so the frontend can
show it even when the failure came from the server rather than the client’s own pre-check.
EventConfig.enforceMemberLocation — a separate, per-config setting from distance-check above. Workers on an
IN_PERSON slot have always had a hard, unconditional requirement to submit location (checkin() throws if
!dto.location, regardless of the distance-check setting). Members had no equivalent — location is
@IsOptional() on CheckInDto, so a member could simply omit it and skip validateLocation() entirely (which
only runs if (dto.location && cfg.venue)), independent of whether distance-check enforcement was on. This setting
lets a tenant require the same of members, scoped per EventConfig (like allowedDistanceInMeters, not the
tenant-wide AttendanceSettingsService/distance-check toggle) — a church can require it for their main Sunday
service’s config while leaving a small-group config unaffected. Deliberately not merged into
enforceDistance()/the distance-check setting — the two answer different questions (“is a too-far check-in
rejected” vs. “must location be submitted at all”) and a config may want either without the other (e.g. requiring
members to share location for record-keeping without necessarily blocking anyone who happens to be far away).
- Storage:
EventConfig.enforceMemberLocation(boolean column, defaultfalse), with a per-slot override —ServiceSlot.enforceMemberLocationOverride(nullable boolean,null= inherit from config) — same override pattern ascheckinStopOverride/allowedDistanceOverride. Resolved inEventService.resolveSlotConfig()(slot.enforceMemberLocationOverride ?? config.enforceMemberLocation) alongside every other per-slot-resolved setting, soAttendanceService.checkin()reads it synchronously off the already-resolved config rather than a separate settings lookup. - Enforcement:
AttendanceService.checkin()— for anIN_PERSONslot, a member omittinglocationwhile the resolvedcfg.enforceMemberLocationistruegetsBadRequestException('Your location is required to check in for this service.'). - DTOs:
CreateEventConfigDto.enforceMemberLocation?: boolean(defaults tofalseinEventConfigService.create()when omitted, same asautoStartSession),CreateServiceSlotDto.enforceMemberLocationOverride?: boolean. - Frontend (admin): discuva-admin’s Event Config page — a toggle inside each config’s create/edit form (alongside “Auto-Start Programme”), not a standalone tenant-wide banner like distance-check. Reflected as a small “Member location required” badge on the config list row and detail view when on.
Distributed absence-marking lock: The every-5-minute cron job acquires a Redis SET NX EX 270 lock before running. If a second instance starts while the first is running, it sees the lock and skips silently. The TTL (270 s) is shorter than the cron interval (300 s) so the lock self-expires if the process crashes mid-run. Department-scoped history endpoints (/history/department, /department/event/:eventId) are automatically scoped to the caller’s own department via their lead-role assignment — no departmentId query parameter is accepted or needed.
Duplicate check-in: The (member, event) unique constraint is enforced at DB level. If a member tries to check in twice for the same event, the service catches the QueryFailedError (PG error code 23505) and returns 409 Conflict with the message “You have already checked in for this event.” checkin() itself only rejects as a duplicate when the existing row is genuinely attended
(GENUINELY_ATTENDED_STATUSES — PRESENT/LATE/ATTENDED_ONLINE, the exact set getAttendanceStreak already uses). An ABSENT/ON_LEAVE row (auto-marked before the member showed up, or left behind by an admin’s “Correct Attendance”) isn’t a real check-in, so it’s updated in place with the real check-in instead of being rejected — previously any existing row, regardless of status, blocked a fresh check-in with a misleading “already checked in at <time>” (the stale row’s leftover checkinTime), while the same status gap in EventService.attachMyAttendance (the query behind GET /events’s per-event checkedIn/myCheckin flags — see Events Module) made the member app’s own check-in button/icon look clickable at the same time — the two disagreed on what “checked in” meant. Both now use the same PRESENT/LATE/ATTENDED_ONLINE definition.
Event data on absent records: Absence records have serviceSlot = null (no physical slot was entered). History endpoints (GET /attendances/my-history, GET /attendances/history, GET /attendances/history/department) join the event relation directly on the Attendance entity rather than through serviceSlot, so event is always populated regardless of status. buildHistoryQb also joins slot.event (aliased slotEvent) separately — discuva-admin’s AttendanceServiceSlot type expects event nested under serviceSlot too (record.serviceSlot.event.name, read unguarded on the Attendance page), which the direct attendance.event join above doesn’t satisfy; omitting this second join left record.serviceSlot.event undefined for every record with a slot, crashing the whole admin Attendance page on load.
Lifetime summary (GET /attendances/my-summary), computed in SQL not client-side: Returns
{ totalCount, presentCount, attendanceRatePercentage, lastCheckedInDate, attendanceStreak } for the calling
member, via AttendanceService.getMyAttendanceSummary. This exists because the mobile app previously derived rate
and streak from whatever page of /attendances/my-history it had fetched (e.g. the last 10 records) — correct only
for a member with 10 or fewer lifetime records, silently wrong for anyone else. attendanceRatePercentage and
totalCount/presentCount are a single aggregate query (COUNT/SUM(CASE WHEN status IN (...))) over the
member’s entire history (not date-windowed, unlike the admin-dashboard-facing getPersonalAttendancePercentage
which defaults to a 30-day window) — ON_LEAVE records are excluded from both numerator and denominator (an
approved leave shouldn’t count against the rate), and PRESENT/LATE/ATTENDED_ONLINE all count as attended.
attendanceStreak delegates to the existing getAttendanceStreak (walks the last 500 records newest-first,
skip ON_LEAVE, break on ABSENT) — already used by the dashboard and already covered by the
(member, roleAtCheckin, createdAt) composite index, so no new index was needed for this endpoint.
Admin-assisted attendance (AttendanceService.adminMarkAttendance): one action covers two cases —
checking in a member/worker with no phone (no Attendance row exists yet for that member+event), and
“restoring a streak” for someone auto-marked ABSENT by the absence-marking cron (a row already exists —
this just updates it). There’s no separate streak field to repair: attendanceStreak is always computed live
from Attendance rows (see getAttendanceStreak above), so fixing/creating the row is the fix. If a record
already exists for (member, event) its status/serviceSlot are updated and checkinTime is left untouched
if already set; otherwise a new record is created with checkinTime = now, roleAtCheckin = the member’s
current role, and location: null (this is an assisted check-in, not GPS-verified). Reachable two ways:
POST /attendances/admin/mark— admin portal,AdminGuard+ATTENDANCE_WRITE.POST /attendances/department/mark— mobile app,JwtAuthGuardonly, gated in-service byassertIsAdminDeptWorker()(the caller’sworkerProfile.departmentorsecondaryDepartmentmust have theFRONT_DESK_OPERATIONScapability — the same capability idiom already used byServiceSessionService.assertIsAdminDeptWorker). Front-desk/Admin-department workers get this without needing an admin-portal login.
Both routes take { memberId, serviceSlotId, status } (AdminMarkAttendanceDto) — the slot determines the
event (slot.event), matching how self-check-in (POST /attendances/checkin) already resolves it. Audit-logged
as ATTENDANCE_ADMIN_MARKED.
Mobile member picker for admin-assisted check-in (GET /attendances/department/search-members?q=):
deliberately narrow — gated by the same assertIsAdminDeptWorker() check, bounded to 10 results
(MemberService.searchActiveMembersLite), and returns only id/firstname/lastname/role (no email or
phone) since this is a lookup for “which person is standing in front of me,” not a general member directory.
This is the one exception in the codebase to “no non-admin member-search endpoint” — justified because the
whole point of this flow is finding one named person on the spot; it’s scoped tightly enough (Admin-department
workers only, minimal fields, capped results) that it doesn’t reopen a general member-picker surface.
Email export (POST /attendances/export-email): same shared pattern as /service-headcount/export-email — filters
the same query GET /attendances/history already runs (no pagination), builds an .xlsx, and emails it via the
report-export template.
Leaderboard chart (discuva-admin, app/attendance/page.tsx): the Leaderboard tab has a Table/Chart toggle —
Chart renders a horizontal present/absent bar per worker via the same components/charts/bar-chart.tsx wrapper
introduced for the headcount trends charts. No new backend aggregation — GET /attendances/leaderboard was already
aggregate-shaped (presentCount/absentCount per worker).
Routes prefix: /attendances
Department Module
Departments are the workforce units. Each can have a head and assistant lead assigned from its worker members. The
optional key field on a department links it to a module-access category (e.g. SUNDAY_SCHOOL, CHILDREN_CHURCH,
MEDIA). Multiple departments can carry the same key.
GET /departments returns the full list (unpaginated) — department count is admin-controlled and bounded. Workers
by department (GET /departments/:id/workers) remains paginated as it can be large.
Routes prefix: /departments
discuva-admin bug fix: the departments list’s own “Leadership” column always read “No leads.”
GET /departments (the plain list call useDepartments()'s fetchDepartments uses) never included a leads
relation at all — DepartmentService.getAll() is a bare find() with no relations, and DepartmentLead isn’t
a column on Department, it’s a separate join table. The frontend’s Department.leads field was simply never
populated by anything, so the table’s Leadership column showed “No leads” for every department regardless of
actual assignment — reported live: assigning an HOD showed correctly in the detail panel (which fetches leads
via the per-department GET /departments/leads/:id endpoint) but the list row next to it stayed stale. Fixed by
a new, deliberately separate fetchAllDepartmentLeads() in hooks/use-departments.ts (one call to
GET /departments/leads, grouped client-side by department id) — not folded into fetchDepartments()
itself, since that one auto-fires on mount for every one of useDepartments()'s other consumers (Announcements,
Workers, Members, Volunteering, bulk-department/bulk-promote), several of which only need department names for
a dropdown and may not even hold DEPARTMENTS_READ — baking the leads call in there would 403 on every one of
those unrelated pages for an admin without that permission. Only app/departments/page.tsx calls the new
function (on mount, on manual Refresh, and after a successful assign/remove-lead), so the fix is scoped to
exactly where the bug was reported.
Pastor Feedback Module
A weekly, structured feedback channel from departments up to the pastorate — the department’s HOD or Assistant HOD (D_HOD) submits it; a pastor reads and responds, from either the admin portal or the mobile app.
Three controllers, one service:
pastor-feedback-worker.controller.ts(JwtAuthGuard) —POST /pastor-feedback(submit),PATCH /pastor-feedback/:id(edit own),GET /pastor-feedback/my(own history). Ownership is checked in-service (HOD/D_HOD of the target department), not by role alone.pastor-feedback-admin.controller.ts(AdminGuard) — cross-department browse/edit/delete (PASTOR_FEEDBACK_READ/WRITE), plusPOST /pastor-feedback/admin/:id/respondfor an admin whose linkedMemberhas aPastorrecord.pastor-feedback-pastor.controller.ts(JwtAuthGuard, mobile-facing) — same cross-department browse plusPOST /pastor-feedback/pastor/:id/respond, gated byassertIsPastor()(anyPastorrecord) rather than an admin permission.
Weekly reminder scheduler (PastorFeedbackReminderScheduler): @Cron('0 9 * * 1', { timeZone: CHURCH_TIMEZONE }) — Monday 9am. Computes weekOf as the Monday of the week that just closed, finds every Department with no PastorFeedback row for that week, and emails + pushes a reminder to the department’s HOD (falling back to the D_HOD if no HOD is assigned; skipped entirely if neither exists). Unlike PrayerReminderScheduler, no reminderSent boolean is needed — the row’s absence is the “still pending” signal, and push de-duplication relies on PushNotificationService’s idempotencyKey (pastor-feedback-reminder:{departmentId}:{weekOf}).
Routes prefix: /pastor-feedback, /pastor-feedback/admin, /pastor-feedback/pastor
Rename data-migration note: the original Department Feedback → Pastor Feedback rename only renamed the
AdminPermission enum values in code (department_feedback:read/write → pastor_feedback:read/write) — it did
not touch already-granted AdminRole.permissions (a raw text[] column checked via a plain .includes() in
AdminGuard, not a normalized join table). Any role granted the old strings before the rename silently lost
access with no error. Fixed by 1788998400000-FixStalePastorFeedbackPermissions.ts, which array_replaces the
old strings for the new ones on every existing admin_roles row. General lesson: any future rename of an
AdminPermission enum value needs a matching data-migration for admin_roles.permissions, not just the code
rename — the enum change alone never reaches rows that already exist.
Prayer Request Module
Lets any member/worker submit a private prayer request and, separately, share an opt-in public testimony —
either tied to one of their own prayer requests or general. This is distinct from the Prayer Roster Module below,
which schedules workers into prayer-meeting duty slots; this module is member-submitted requests with a lifecycle
(OPEN → PRAYED_FOR → ANSWERED), unrelated to any meeting.
Visibility:
- Prayer requests are visible only to the submitter, workers in the Prayer department, and pastors — never a
public wall. Testimonies default to private; the submitter alone decides at submission time whether theirs
appears on the shared public feed (
isPublic) — there is no separate admin moderation/publish step.
Entities: PrayerRequest (prayer_requests) and Testimony (testimonies), both with a nullable
member FK (SET NULL) plus a submittedByName snapshot, mirroring PastorFeedback.submittedBy’s pattern so a
deactivated member’s history survives. A Testimony.prayerRequest FK (nullable, SET NULL) links it to a specific
request; null means a general testimony not tied to any request.
Three controllers, one service (PrayerRequestService):
prayer-request-worker.controller.ts(JwtAuthGuard) —POST /prayer-requests(submit),GET /prayer-requests/mine(own history),POST /testimonies(submit, optionalprayerRequestId— enforced to be the caller’s own request),GET /testimonies/mine,GET /testimonies/public(the opt-in feed, open to any authenticated member/worker).prayer-request-team.controller.ts(JwtAuthGuard, mobile-facing) —GET /prayer-requests/team,PATCH /prayer-requests/team/:id/status. Gated in-service byassertIsPrayerTeamOrClergy(): any worker whose primary or secondary department has theMANAGE_PRAYER_REQUESTScapability, or any member with aClergyrecord.prayer-request-admin.controller.ts(AdminGuard) —GET /prayer-requests/admin,PATCH /prayer-requests/admin/:id/status,GET /testimonies/admin(full visibility, not just public ones). Reuses the existingPRAYER_READ/PRAYER_WRITEpermissions (already grouped under “Prayer Roster” inAdminPermissionGroups) — no new permission was introduced, since this is the same overall “Prayer” domain.
Routes prefix: none fixed — routes span /prayer-requests* and /testimonies* across the three controllers
(each controller uses @Controller() with a full path per handler rather than a single shared prefix, since the
two resources don’t share one).
Pregnancy prayer tracking: the same module also tracks pregnant women receiving ongoing prayer support —
PregnancyPrayerCase (name, EDD, details, status) plus PregnancyPrayerVisit (a log entry per prayer/visit,
mirroring the FirstTimerVisit idiom in the Follow-Up module). Unlike prayer requests, these are created and
managed entirely by the Prayer team/clergy on the woman’s behalf — there is no worker-facing self-submit
controller. PregnancyPrayerCase.lastPrayedAt is denormalized and updated whenever a new visit is logged, so the
UI can show “last prayed” without joining the visit log on every read. Reuses PRAYER_READ/PRAYER_WRITE — no new
permission. Every PregnancyPrayerVisit is also readable back via GET prayer-requests/team/pregnancy-cases/:id/visits (mobile) and GET prayer-requests/admin/pregnancy-cases/:id/visits (admin, PRAYER_READ) — paginated, newest first, so the full
visit-and-note history is reviewable, not just the denormalized lastPrayedAt date. Routes: GET/POST prayer-requests/team/pregnancy-cases, POST prayer-requests/team/pregnancy-cases/:id/visit, PATCH prayer-requests/team/pregnancy-cases/:id/status, GET prayer-requests/team/pregnancy-cases/:id/visits (mobile,
assertIsPrayerTeamOrClergy gated) and the parallel GET prayer-requests/admin/pregnancy-cases, PATCH prayer-requests/admin/pregnancy-cases/:id/status, GET prayer-requests/admin/pregnancy-cases/:id/visits (admin
portal oversight, read + status-only — case creation and visit logging stay Prayer-team/mobile-only by design).
Leave Module
Workers request leave with a date range. Approved leave is checked by the cron job: if a worker has approved leave overlapping a slot’s time range, they are marked ON_LEAVE instead of ABSENT.
Submission guards:
- A worker with a
PENDINGrequest cannot submit another until the first is actioned. - A worker cannot submit a request whose date range overlaps any already-approved leave (
dateFrom ≤ request.dateTo AND dateTo ≥ request.dateFrom). Returns400 Bad Request.
Date columns (dateFrom, dateTo): stored as PostgreSQL date (no time component, format YYYY-MM-DD). Overlap checks compare date strings to avoid timezone shifts.
Routes prefix: /leave
Classes Module (displayed as “Training Classes”)
Tracks member progress through structured church programs. The module, route path (/classes), and permission keys (classes:read/classes:write) are unchanged — only the user-facing label was renamed from “Classes”/“Church Classes” to “Training Classes” (sidebar nav, breadcrumbs, page headings, permission labels). Renaming the permission enum keys would strand existing admin_roles.permissions rows (see the Pastor Feedback rename note under the Pastor Feedback Module section), so only display strings changed.
Class types: admin-defined via ClassType CRUD (/classes/types) — not a fixed enum. Each type optionally points to a nextClassType, forming an admin-configured promotion chain (see the ClassType entity section above). Deactivating a type (isActive: false) hides it from new-class pickers without breaking existing classes that reference it.
Enrollment statuses: IN_PROGRESS → COMPLETED or CANCELLED. A COMPLETED enrollment whose class type has a nextClassType becomes eligible for level promotion (GET classes/enrollments/:id/promotion-candidate → POST classes/enrollments/:id/promote) — an explicit, separate, admin-confirmed action, not automatic.
Certificates: A COMPLETED enrollment can be marked as having received a certificate via PATCH classes/enrollments/:id/certificate (optional certificateNumber; auto-numbered when omitted, and downloadable as a PDF — see “Sessions, attendance… certificates” below).
Guest enrollment: non-members can take a class alongside members — see the Guest/ClassEnrollment entity sections above for the data model and portal-access mechanics. POST classes/enroll/guest (body: EnrollGuestDto — classId + either guestId for a returning guest, or firstName/lastName/email(+phone/churchName/address/notes) for a new one, + optional per-enrollment purpose) enrolls a single guest, finding-or-creating the Guest row by email and sending the class-guest-access portal-link email on a fresh enrollment (not on re-enrollment of a CANCELLED row). POST classes/enroll/guests/bulk (body: BulkEnrollGuestsDto — classId + guests: {firstName, lastName, email, phone?}[]) loops the same logic per entry, catching and logging per-entry failures rather than aborting the whole batch, and returns { enrolled, skipped } — mirroring bulkEnrollMembers’s all-or-nothing-per-row (not all-or-nothing-per-batch) behavior. Both are blocked (400) against a CLOSED class.
Guest management (GET classes/guests, GET classes/guests/:id): GET classes/guests is a paginated, search-by-name/email list of every guest across all classes (permission CLASSES_READ) — the answer to “how does an admin see/manage a guest across multiple classes” without digging through one class’s enrollment tab at a time. GET classes/guests/:id returns one guest’s profile plus every ClassEnrollment they’ve ever had, across all classes.
Guest-to-member conversion: POST classes/guests/:guestId/convert-to-member (permission CLASSES_WRITE) — see the Guest entity section above.
Guest portal access (no login): GET classes/guest/:enrollmentId and POST classes/guest/:enrollmentId/assignments/:assignmentId/submit, both @Public() (bypass JwtAuthGuard; ModuleEnabledGuard still applies since it keys off the tenant, not caller auth) on a dedicated ClassPublicController — mirrors the Forms module’s public-submission pattern exactly. The submit route is rate-limited (5/min). submitAsGuest verifies the enrollment actually has a guest attached and belongs to the same class as the assignment, so neither a member’s enrollment id nor a guest’s enrollment in a different class can be used to submit.
Study materials — see the ClassMaterial entity section above for the full model (multiple titled
documents/links per class, upload vs. pasted-link vs. reuse-existing-asset, and the reference-counted delete
behavior that keeps a shared “reused” upload safe). Upload accepts PDF, Word, PowerPoint, or image mimetypes; size
is capped by PlatformSettingKey.MAX_CLASS_MATERIAL_UPLOAD_MB (platform-admin-configurable, default 10 MB —
separate from the app-wide MAX_FILE_UPLOAD_BYTES default of 5 MB since course material tends to run larger than
proofs/images). Materials can only be added to a class that already exists — there’s no material field on
POST classes itself.
Assignments (Assignment/AssignmentSubmission, tables assignments/assignment_submissions): each
ChurchClass can have any number of assignments. An assignment has title, optional instructions, maxScore
(default 100), optional dueDate, and isPublished (default true — an unpublished assignment is an admin-only
draft, invisible to students and not submittable). A student submits free-text content against a published
assignment; one submission per member per assignment (UNIQUE(assignment_id, member_id)) — resubmitting before
grading overwrites the existing row (submittedAt bumped), but once graded (gradedAt set) further resubmission
is rejected with 400, so a grade can’t be silently invalidated by a late edit. Grading (PATCH classes/assignments/submissions/:submissionId/grade) sets score (validated <= assignment.maxScore, 400
otherwise), optional feedback, and stamps gradedBy (the grading Admin, resolved via @CurrentAdmin() — not
the JWT’s member id) + gradedAt. gradedBy is SET NULL on admin deletion, mirroring the reviewedBy pattern
used by tithe/finance proof review.
Guest submissions: AssignmentSubmission.member is nullable; a guest’s submission is keyed by classEnrollment (ManyToOne → ClassEnrollment, onDelete: CASCADE) instead — DB-level CHECK constraint (member_id IS NOT NULL) != (class_enrollment_id IS NOT NULL) (exact XOR, unlike ClassEnrollment’s own OR constraint): a submission is made either as an authenticated member OR via a specific guest enrollment, never both, and converting a guest later doesn’t rewrite past submissions. UNIQUE(assignment_id, class_enrollment_id) mirrors the member-side UNIQUE(assignment_id, member_id). submitAsGuest() is the guest-portal equivalent of submit() — same overwrite-before-grading / reject-after-grading rules, reached only via ClassPublicController (see Guest portal access above).
Progress summary: both GET classes/:classId/assignments/available (member) and GET classes/guest/:enrollmentId (guest) return { assignments: [...], progress: { submitted, total } } — the progress summary is derived from the same published-assignments-plus-submissions query already being run, not a separate round-trip. Breaking change from the prior shape: available used to return a bare Assignment[]; callers must now read .assignments.
| Method | Route | Auth | Notes |
|---|---|---|---|
| POST | /classes/:classId/assignments |
AdminGuard (CLASSES_WRITE) | Create an assignment for a class |
| GET | /classes/:classId/assignments |
AdminGuard (CLASSES_READ) | All assignments for a class, including unpublished drafts |
| GET | /classes/:classId/assignments/available |
JwtAuthGuard | { assignments, progress } — published assignments only, each merged with the caller’s own mySubmission (null if not yet submitted) |
| PATCH | /classes/assignments/:assignmentId |
AdminGuard (CLASSES_WRITE) | Partial update |
| DELETE | /classes/assignments/:assignmentId |
AdminGuard (CLASSES_WRITE) | Cascades submissions |
| POST | /classes/assignments/:assignmentId/submit |
JwtAuthGuard | Create or (if ungraded) overwrite the caller’s own submission |
| GET | /classes/assignments/:assignmentId/submissions |
AdminGuard (CLASSES_READ) | Paginated (?page=&limit=), for grading |
| PATCH | /classes/assignments/submissions/:submissionId/grade |
AdminGuard (CLASSES_WRITE) | { score, feedback? } |
| POST | /classes/:id/materials/upload |
AdminGuard (CLASSES_WRITE) | Multipart, field file (+ optional title) — uploads to Cloudinary and creates the ClassMaterial row |
| POST | /classes/:id/materials/link |
AdminGuard (CLASSES_WRITE) | { title, url } — pasted external link, no Cloudinary asset |
| POST | /classes/:id/materials/reuse |
AdminGuard (CLASSES_WRITE) | Echoes a library entry’s fields — new row, same underlying asset, no re-upload |
| DELETE | /classes/:id/materials/:materialId |
AdminGuard (CLASSES_WRITE) | Reference-counted Cloudinary cleanup — see ClassMaterial above |
| GET | /classes/materials/library |
AdminGuard (CLASSES_READ) | { title, url, publicId, resourceType, mimeType, sizeBytes, usedByClassNames }[] — for the “Reuse Previous” picker |
| GET | /classes/lookup |
AdminGuard (ANNOUNCEMENTS_WRITE) | {id, name, startDate, endDate}[] — feeds the Announcements CLASS-audience picker; gated on ANNOUNCEMENTS_WRITE (composing an announcement), not CLASSES_READ, mirroring GET /groups/lookup |
| PATCH | /classes/:id/session |
AdminGuard (CLASSES_WRITE) | { nextSessionAt?, meetingLink? } — either can be set to null to clear |
| POST | /classes/enroll/guest |
AdminGuard (CLASSES_WRITE) | EnrollGuestDto — see Guest enrollment above |
| POST | /classes/enroll/guests/bulk |
AdminGuard (CLASSES_WRITE) | BulkEnrollGuestsDto → { enrolled, skipped } |
| GET | /classes/guests |
AdminGuard (CLASSES_READ) | Paginated, ?search= by name/email |
| GET | /classes/guests/:id |
AdminGuard (CLASSES_READ) | Guest profile + every enrollment across all classes |
| POST | /classes/guests/:guestId/convert-to-member |
AdminGuard (CLASSES_WRITE) | See Guest-to-member conversion above |
| GET | /classes/guest/:enrollmentId |
@Public() |
Class info (incl. nextSessionAt/meetingLink) + guest name + assignments + progress, plus schedule (with myStatus), attendance summary and enrollment certificate status |
| POST | /classes/guest/:enrollmentId/assignments/:assignmentId/submit |
@Public(), rate-limited (5/min) |
{ content } |
Assignments reuse the existing classes:read/classes:write permissions rather than adding new ones — managing
assignments is the same admin surface as managing the classes they belong to.
Sessions, attendance, progress, facilitators, requests, certificates, reports (added 2026-10-01)
New tables (tenant migration 1799910000000-AddTrainingClassSessionsAndRequests): class_sessions,
class_session_attendances, class_join_requests; new church_classes columns (see ChurchClass); and
assignment_submissions.graded_by_member_id. All routes live on ClassTrainingController, registered before
ClassesController so literal paths (teaching, reports, my/..., join-requests/...) aren’t captured by :id.
Sessions (ClassSession): one scheduled day of a class — startsAt, optional endsAt, mode
(PHYSICAL/VIRTUAL/HYBRID), optional title, location, meetingLink, notes. Create one, or a series
({ startDate, endDate, time: "HH:mm", durationMinutes?, everyWeeks: 1–4, title?, mode, location?, meetingLink?, notes? })
— every N weeks at the same local time in the church timezone (wallTimeToUtc, DST-safe), ≤60 per call, skipping
start times that already exist; returns { created, skipped: date[] }. Every session ends: without endsAt (single) or
durationMinutes (series) the session lasts 2 hours (DEFAULT_SESSION_MINUTES); moving a session’s start without a
new end keeps its length, and sending endsAt: null resets it to 2 hours. Migration
1800082800000-BackfillClassSessionEndTimes gives any older session without an end the same default. A session with attendance can’t be deleted (400),
only edited. Every create/edit/delete re-syncs the class’s nextSessionAt/meetingLink, and
ClassSessionReminderScheduler calls advancePastNextSessions() each hour before its sweep, so existing session
reminders follow the schedule.
Attendance (ClassSessionAttendance): keyed by enrollment (members and guests alike), UNIQUE(session, enrollment),
status PRESENT/ABSENT/EXCUSED, markedByAdmin or markedByMember (facilitator). Roster = every non-cancelled
enrollment with its mark; marking upserts and silently skips enrollments not on the class.
Progress & completion rules (ClassProgressService): per non-cancelled enrollment — sessions held since the day
they enrolled (church timezone), present/absent/excused, attendancePercent = present ÷ (held − excused), published
assignments submitted, averageScorePercent (graded scores as % of each max), meetsRules, and missing[]
(plain-language reasons). Closing a class (PATCH /classes/:id/close) now returns
{ closedEnrollments, needsReview: [{ enrollmentId, name, missing }] }: with rules set, only IN_PROGRESS people who meet
them are completed and the rest stay IN_PROGRESS for an admin decision; with no rules, everyone is completed as before.
Facilitators (member app): a member listed as a ClassFacilitator of a class can, for that class only, manage
sessions, mark attendance, see progress, list assignments with submitted/graded counts and grade submissions
(gradedByMember is set instead of gradedBy). Non-facilitators get 403. GET /classes/teaching lists the caller’s classes.
Join requests (ClassJoinRequest): members ask to join a class with openForRequests; refused if the class is
closed, full (capacity vs IN_PROGRESS count), they’re already in it (a CANCELLED enrolment may ask again) or they
already have a PENDING request (partial unique index UQ_class_join_requests_pending). Approving enrols them via
ClassesService.enrollMember and pushes CLASS_JOIN_APPROVED; declining stores an optional reason and pushes
CLASS_JOIN_DECLINED. Members can withdraw a pending request. GET /classes/:id/join-status tells the app where the
caller stands (openForRequests, classClosed, capacity, spotsLeft, enrollmentStatus, enrollmentId, latest request).
Certificates: issuing without a number now assigns the next CERT-YYYY-NNNN (per calendar year, serialised with
pg_advisory_xact_lock, ordered by length then value) and pushes CLASS_CERTIFICATE_READY to members. A typed number is
kept as given. POST /classes/:id/certificates/issue-all issues for every COMPLETED enrollment without one. The PDF
(PdfService.generateClassCertificate, landscape A4) uses the tenant’s name and logo, the class and class-type name,
the completion date and the first two facilitators as signatories; available to admins, to the member (own enrollment
only) and to guests via their portal link.
Reports (ClassReportService): classes running in [from, to] (open-ended dates count) optionally by
classTypeId: per class enrolled/in progress/completed/cancelled, completionRate (completed ÷ everyone enrolled),
sessions held, averageAttendance (mean of people’s attendance %), certificates; totals; and next steps — for each
class type with a nextClassType, members who completed it (completedAt in range) and how many have a non-cancelled
enrollment in the next type. Excel export: Classes, Next steps, People sheets.
Performance & indexes: ClassProgressService.progressFor(classIds, enrollmentIds?) computes any number of classes
(or just some enrollments) in a fixed set of queries — reports call it once for every class in range, and a member’s
or guest’s own view asks for their enrollment only. Member/guest schedules skip the attendance totals and reuse the
session list. Next-steps uses one CTE query per class type. issue-all takes the numbering lock once, saves every
certificate in one write and sends one push. Indexes: class_sessions(church_class_id, starts_at);
class_session_attendances UNIQUE (session_id, enrollment_id) + (enrollment_id); class_join_requests
(church_class_id), (member_id) and the partial unique (church_class_id, member_id) WHERE status='PENDING' (also
serves pending counts); assignment_submissions(graded_by_member_id); and
IDX_class_enrollments_certificate_number (text_pattern_ops, partial on non-null, migration
1799996400000-AddClassEnrollmentCertificateNumberIndex) for the LIKE 'CERT-YYYY-%' lookup. Existing indexes cover
the rest (class_enrollments by class and the unique (member_id, church_class_id), class_facilitators(member_id),
assignments(church_class_id), assignment_submissions(assignment_id)).
Notifications: new EmailCategory.TRAINING_CLASSES (“Training Class Updates”, env EMAIL_TRAINING_CLASSES_ENABLED),
push only: CLASS_JOIN_APPROVED, CLASS_JOIN_DECLINED, CLASS_CERTIFICATE_READY.
| Method | Route | Auth | Notes |
|---|---|---|---|
| GET | /classes/:id/sessions |
AdminGuard (CLASSES_READ) | Sessions with held and attendance counts |
| POST | /classes/:id/sessions |
AdminGuard (CLASSES_WRITE) | { startsAt, endsAt?, title?, mode?, location?, meetingLink?, notes? } |
| POST | /classes/:id/sessions/series |
AdminGuard (CLASSES_WRITE) | See Sessions above → { created, skipped } |
| PATCH | /classes/sessions/:sessionId |
AdminGuard (CLASSES_WRITE) | Any session field; null/empty clears optional text |
| DELETE | /classes/sessions/:sessionId |
AdminGuard (CLASSES_WRITE) | 400 once attendance exists |
| GET | /classes/sessions/:sessionId/roster |
AdminGuard (CLASSES_READ) | { session, entries: [{ enrollmentId, name, email, isGuest, status }] } |
| POST | /classes/sessions/:sessionId/attendance |
AdminGuard (CLASSES_WRITE) | { attendances: [{ enrollmentId, status }] } (≤500) → { marked } |
| GET | /classes/:id/progress |
AdminGuard (CLASSES_READ) | { rules, sessionsHeld, sessionsTotal, people[] } |
| PATCH | /classes/:id/close |
AdminGuard (CLASSES_WRITE) | → { closedEnrollments, needsReview[] } (rule-aware) |
| GET | /classes/:id/join-requests |
AdminGuard (CLASSES_READ) | Pending first, then recent decisions (≤200) |
| GET | /classes/join-requests/pending-counts |
AdminGuard (CLASSES_READ) | { [classId]: count } |
| POST | /classes/join-requests/:requestId/approve |
AdminGuard (CLASSES_WRITE) | Enrols the member → the enrollment |
| POST | /classes/join-requests/:requestId/decline |
AdminGuard (CLASSES_WRITE) | { reason? } |
| POST | /classes/:id/certificates/issue-all |
AdminGuard (CLASSES_WRITE) | → { issued } |
| GET | /classes/enrollments/:enrollmentId/certificate |
AdminGuard (CLASSES_READ) | |
| GET | /classes/reports/summary?from&to&classTypeId |
AdminGuard (CLASSES_READ) | { from, to, totals, classes[], pipeline[] } |
| GET | /classes/reports/export?from&to&classTypeId |
AdminGuard (CLASSES_READ) | .xlsx |
| GET | /classes/teaching |
JwtAuthGuard | Classes the caller facilitates, with enrolledCount |
| GET | /classes/teaching/:id/sessions |
Facilitator of the class | As the admin list |
| POST | /classes/teaching/:id/sessions |
Facilitator of the class | Create a session |
| POST | /classes/teaching/:id/sessions/series |
Facilitator of the class | Create a series |
| PATCH | /classes/teaching/sessions/:sessionId |
Facilitator of the class | Edit a session |
| DELETE | /classes/teaching/sessions/:sessionId |
Facilitator of the class | Delete (no attendance yet) |
| GET | /classes/teaching/sessions/:sessionId/roster |
Facilitator of the class | Roster |
| POST | /classes/teaching/sessions/:sessionId/attendance |
Facilitator of the class | Mark attendance (markedByMember) |
| GET | /classes/teaching/:id/progress |
Facilitator of the class | Progress |
| GET | /classes/teaching/:id/assignments |
Facilitator of the class | Assignments with submittedCount, gradedCount |
| GET | /classes/teaching/assignments/:assignmentId/submissions |
Facilitator of the class | Paginated submissions (members and guests) |
| PATCH | /classes/teaching/submissions/:submissionId/grade |
Facilitator of the class | { score, feedback? } |
| GET | /classes/:id/my-progress |
JwtAuthGuard | { enrolled, schedule[], progress, rules, join } — schedule for anyone; own marks/progress only when enrolled; join is the same object as /join-status so the class page needs one call |
| GET | /classes/:id/join-status |
JwtAuthGuard | See Join requests above |
| POST | /classes/:id/join-requests |
JwtAuthGuard | { message? } |
| DELETE | /classes/join-requests/:requestId |
JwtAuthGuard | Withdraw own pending request (204) |
| GET | /classes/my/join-requests |
JwtAuthGuard | Own requests (≤50) |
| GET | /classes/my/enrollments/:enrollmentId/certificate |
JwtAuthGuard | Own certificate PDF |
| GET | /classes/guest/:enrollmentId/certificate |
@Public(), 10/min |
Guest’s certificate PDF |
Routes prefix: /classes, /classes/types
Announcements Module
Audience-targeted broadcast messages. The /announcements/feed endpoint filters automatically based on the caller’s
role and optional departmentId.
Audience rules:
- MEMBER → sees
ALL+MEMBERS_ONLY+ anyINDIVIDUALannouncements addressed to them + anyGROUPannouncement for a group they belong to + anyCLASSannouncement for a class they’re enrolled in (IN_PROGRESS/COMPLETED, as a member — guests have no login and so never see the feed) - WORKER → sees
ALL+WORKERS_ONLY+DEPARTMENT(for their department) +INDIVIDUAL(addressed to them) + anyGROUPannouncement for a group they belong to + anyCLASSannouncement for a class they’re enrolled in - ADMIN → sees all audiences
- Expired announcements (
expiresAt < now) are excluded from the feed
Audience types: ALL | WORKERS_ONLY | MEMBERS_ONLY | DEPARTMENT | INDIVIDUAL | GROUP | CLASS
When audience = DEPARTMENT, departmentId is required. When audience = INDIVIDUAL, targetMemberId (UUID) is
required. When audience = GROUP, groupId (UUID) is required. When audience = CLASS, classId (UUID) is
required. Since ChurchClass already has startDate/endDate — each row is one dated cohort — picking a specific
class already scopes the audience to a specific date range; there’s no separate date-range picker. CLASS audience
targets IN_PROGRESS + COMPLETED enrollees only (excludes CANCELLED).
Push notification on every audience: AnnouncementService.create() always fire-and-forgets a single PushNotificationService.dispatchToMemberIds() call after saving, for every audience type — not just GROUP. resolveMemberIdsForAudience() computes the recipient member-id list per audience (GROUP → GroupService.getMemberIdsForGroup; CLASS → ClassesService.getMemberIdsForClass — member-linked enrollees only, guests have no member id to push to; INDIVIDUAL → the single targetMember; ALL/MEMBERS_ONLY/WORKERS_ONLY/DEPARTMENT → an ACTIVE-member query filtered by role/department, mirroring resolvePhoneNumbers’s SMS-targeting logic but without requiring a phone number on file). No push is sent when the resolved list is empty. Idempotency key = the announcement id, so a retry or duplicate call never double-sends. Failure to dispatch is logged as a warning and never fails the announcement creation itself — the announcement is still visible in-app via the feed regardless of push delivery outcome.
Optional SMS delivery (sendViaSms/smsBody): CreateAnnouncementDto/UpdateAnnouncementDto accept
sendViaSms?: boolean and smsBody?: string. Setting sendViaSms: true requires the caller’s admin role to hold
the SMS_SEND permission (checked in AnnouncementService, not the DTO — a DTO can’t inspect the caller’s
permission set — throws 403 Forbidden otherwise) and requires smsBody to be non-empty. smsBody is deliberately
separate from body: the announcement body is often long-form and meant for in-app reading, whereas SMS is billed
per segment, so admins compose a distinct, short message for it. SMS sends are allowed from 08:00 through 19:50 in
CHURCH_TIMEZONE (default Africa/Lagos) for every provider. On create, an SMS is sent (awaited) whenever
sendViaSms is set; the announcement is still saved if sending fails, and the response includes a transient
smsDispatch result (accepted, failed, or skipped) and any provider error message. On update, the SMS is sent
only on the transition into sendViaSms=true — re-saving an already-SMS’d announcement (e.g. editing its title
afterward) does not re-text everyone. accepted means the provider accepted the request, not that the carrier
delivered every message; use the SMS Logs page for current provider delivery status and its matching local send
record, including the announcement ID and any provider error.
SMS phone number resolution (resolvePhoneNumbers) — independent of the push-notification audience logic above.
Always restricted to ACTIVE members with a non-null phoneNumber, further filtered by audience:
ALL— every eligible memberMEMBERS_ONLY— role = MEMBERWORKERS_ONLY— role = WORKERDEPARTMENT— workers whoseworkerProfile.departmentmatchesannouncement.departmentINDIVIDUAL— justannouncement.targetMemberGROUP— members resolved viaGroupService.getMemberIdsForGroup(announcement.group.id)CLASS— union of member-linked enrollees’ phones (ClassesService.getMemberIdsForClass, filteredACTIVE+phone-on-file like every other audience) and guest enrollees’ own phones (ClassesService.getGuestPhonesForClass— a guest has noMemberrow, so their ownGuest.phone, if set, is the only channel besides email), deduped — the same dual-source shaperesolveGroupPhoneNumbersalready uses for member vs. phone-onlyGroupMemberentries
resolvePhoneNumbers takes a plain { audience, departmentId?, targetMemberId?, groupId?, classId? } target rather than an
Announcement entity, so it’s reusable outside the announcement-creation flow — see sendSmsBroadcast below.
SMS-only broadcast (POST /announcements/sms-broadcast), no announcement created: For sending a text blast to an
audience without publishing anything to the in-app feed. Guarded solely by SMS_SEND (not ANNOUNCEMENTS_WRITE —
an admin with SMS access but no announcement-authoring access can use this). Body: SendSmsBroadcastDto —
audience (required) + the matching departmentId/targetMemberId/groupId/classId for DEPARTMENT/INDIVIDUAL/GROUP/CLASS
audiences + message (required). Reuses the same resolvePhoneNumbers targeting as sendViaSms on a regular
announcement. No Announcement row is created, no push notification is sent, and no title/body is required — this
is purely an SMS send. Returns { sentCount }; sentCount: 0 (not an error) when the resolved audience has no
members with a phone number on file. Returns { sentCount, failedCount?, failures? }; failed recipients are returned
as a normal response so the tenant transaction can commit their local failure records. Failures are audited as
SMS_BROADCAST_FAILED, not as sent. A failure may follow partial provider acceptance, so check SMS Logs before
retrying. Successful requests log SMS_BROADCAST_SENT (metadata: { audience, count }); sentCount records
provider acceptance, not carrier delivery.
Emoji reactions: any authenticated member/worker can react to an announcement with one of a fixed emoji set
(ReactionEmojiEnum: 👍 ❤️ 🙏 🎉 👏) via POST announcements/:id/react — reacting again just updates the existing
reaction (one per member per announcement, not multi-emoji). DELETE announcements/:id/react removes it.
GET announcements/:id/reactions returns { summary: { emoji, count }[], myReaction: string | null } —
myReaction reflects the calling member’s own reaction so the frontend can highlight it without a separate
lookup. No audit logging on reactions — too high-frequency/low-stakes to be worth an audit trail entry per click.
System-triggered announcements (createSystemAnnouncement): a small internal entry point used by features that
need to publish an ALL-audience announcement without going through the admin-authored create() flow — no
Admin/SMS-permission check, author is left null (the FK is nullable for exactly this reason), publishedAt is
now, and the same persist + push-notify path runs. Used today by the Sermon Archive’s “Announce Live” trigger (see
Sermon Module); designed to also be the target of the planned YouTube WebSub livestream-detection integration.
Routes prefix: /announcements
Picking a group when creating a GROUP announcement does not require groups:read/groups:write — the admin
frontend’s group picker (GroupSearchInput, a searchable combobox filtering the already-fetched list client-side
rather than a plain <select>) calls GET /groups/lookup via useGroupLookup() (see Groups Module), gated on
announcements:write only, since choosing a group here is a component of the announcement feature rather than a
separate group-management capability.
Picking a class when creating a CLASS announcement mirrors the group picker exactly, one permission layer down: ClassSearchInput calls GET /classes/lookup (see Classes Module — same announcements:write-only gating, same reasoning), showing each class’s name plus its startDate/endDate so an admin can distinguish cohorts of the same class type.
Groups Module
Reusable, admin-managed rosters of members and/or workers (e.g. “Call Leaders”) used to target announcements at a
fixed group of people without re-selecting individuals each time. A group’s membership is independent of
Department — a group can mix members and workers from any department.
Entities:
Group(groupstable) —name(unique),description(nullable),createdBy(nullable FK →members,SET NULL).GroupMember(group_memberstable) — join entity. A row is either a real Member (memberFK set) or a phone-only entry (phoneNumber+ optionallabelset,membernull) — e.g. a manually-typed number, or one imported from aFirstTimerwho has noMemberaccount (first-timers can’t join Groups any other way, since they aren’tMemberrecords). Enforced by aCHECKconstraint (member_id IS NOT NULL AND phone_number IS NULLOR the reverse) added in migrationAddGroupMemberPhoneEntries, plus service-layer logic that only ever sets one side.@Unique(['group', 'member'])and@Unique(['group', 'phoneNumber'])both apply — Postgres treats NULLs as distinct per unique index, so real-member rows (phoneNumber null) and phone-only rows (member null) don’t collide with each other’s constraint.group/memberFKsON DELETE CASCADE(removing a group or a member cleans up membership rows automatically),addedBy(nullable FK →members,SET NULL).
Permissions: groups:read, groups:write (grouped under “Announcements” in AdminPermissionGroups, since a group’s only current purpose is targeting announcements).
Routes prefix: /groups
| Method | Path | Description |
|---|---|---|
| GET | /groups |
List all groups with memberCount (no pagination — reference data, mirrors the Departments policy). memberCount counts all group_members rows regardless of kind, so phone-only entries count too. |
| GET | /groups/lookup |
Minimal {id, name}[] list, gated on announcements:write instead of groups:read — lets any admin who can create a GROUP announcement populate the group picker without also needing group-management access. Registered before :id so it isn’t swallowed as a param. |
| GET | /groups/:id |
Get one group with memberCount |
| POST | /groups |
Create a group (name, optional description) |
| PATCH | /groups/:id |
Rename / update description |
| DELETE | /groups/:id |
Delete a group (cascades to its group_members rows) |
| GET | /groups/:id/members |
Paginated roster (page, limit; can grow large, mirrors the Workers-by-Department policy) — leftJoins the member relation so phone-only rows are included, not just real members |
| POST | /groups/:id/members |
Add a single real member (memberId) |
| POST | /groups/:id/members/bulk-add |
Add multiple real members at once (memberIds: string[]); returns {added, skipped} — duplicates are skipped, not errored |
| POST | /groups/:id/members/phone |
Add phone-only entries directly. Body: { entries: { phoneNumber, label? }[] }; numbers normalize to E.164 using CURRENCY_LOCALE; any invalid entry rejects the batch with 400 naming the number ("<number>" is not valid. <invalidPhoneMessage>). Returns {added, skipped} — duplicate normalized phone numbers within the group are skipped |
| POST | /groups/:id/members/first-timers |
Bulk-import every FirstTimer captured within a date range as phone-only entries (label = their name). Body: { dateFrom, dateTo } (ISO 8601); returns {added, skipped, invalid} — first-timers whose stored phone doesn’t normalize are counted in invalid and skipped, never failing the import |
| DELETE | /groups/:id/members/:memberId |
Remove a single real member by member id (kept for backward compatibility — cannot address phone-only rows, which have no member id) |
| POST | /groups/:id/members/bulk-remove |
Remove multiple real members at once by member id (memberIds: string[]); returns {removed} |
| DELETE | /groups/:id/entries/:entryId |
Remove a single roster entry by its own GroupMember row id — works for both real members and phone-only entries |
| POST | /groups/:id/entries/bulk-remove |
Remove multiple roster entries at once by row id (entryIds: string[]); returns {removed} |
| DELETE | /groups/:id/entries |
Remove every entry (members and phone-only) from the contact list, keeping the group; returns {removed}. Backs the admin roster’s “Select all N in this list” |
Resolving a group’s phone numbers for SMS (AnnouncementService.resolveGroupPhoneNumbers, used by both regular
announcement sendViaSms and the dedicated SMS-only broadcast): unions two sources — active Members in the group
with a phone number on file (via GroupService.getMemberIdsForGroup, then a direct Member query for the
active/phone-on-file filter) and raw phone-only entries (GroupService.getPhoneOnlyNumbersForGroup) — deduped
via Set. Phone-only entries have no “active” concept; they’re included as-is.
Indexes (migration AddGroupsModule): group_members(group_id) and group_members(member_id) back the roster listing and the group-audience membership check used by the announcement feed’s EXISTS subquery; announcements(group_id) backs the same feed query.
SMS Module
Provider-agnostic SMS sending — pure BYOK, no platform-default account and no prepaid credit balance. A tenant must configure and activate their own SMS provider (Communication Providers below) before they can send at all; there is no fallback. This is deliberate: a platform-run wallet billed in Naira only ever made sense for Nigerian tenants — BYOK lets a tenant in any country pick whichever SMS vendor actually serves them and pay that vendor directly.
Provider abstraction (src/sms/interface/sms-provider.interface.ts):
type SmsProviderCredentials = Record<string, string>; // e.g. Termii's { apiKey, senderId }, Twilio's { accountSid, authToken, fromNumber }
interface ISmsProvider {
readonly maxRecipientsPerRequest: number; // Termii: 100 (true bulk endpoint); Twilio: 20 (concurrency cap, no bulk endpoint)
send(to: string[], message: string, encoding: 'plain' | 'unicode', credentials: SmsProviderCredentials): Promise<{ messageId: string; status: string }>;
getBalance(credentials: SmsProviderCredentials): Promise<{ balance: number; currency: string }>;
getMessageHistory(credentials: SmsProviderCredentials): Promise<SmsLogEntry[]>;
}
SmsProviderRegistryService (src/sms/service/sms-provider-registry.service.ts, same shape as billing’s
PaymentProviderRegistryService) holds every registered vendor simultaneously — termii → TermiiSmsProvider,
twilio → TwilioSmsProvider — and SmsService resolves which one to use per call from the tenant’s active
TenantCommunicationProviderConfig.providerId, never a hardcoded class. Adding a vendor is a new ISmsProvider
class, a line in the registry, and a communication_providers catalog row — no other call site changes.
SmsService:
calculateSegments(message)— determines encoding and segment count for billing purposes. A message is encodedplain(GSM-7, 160 chars/segment) unless it contains a non-ASCII character or one of the characters Termii documents as forcing UCS-2/unicode encoding even though they’re otherwise ordinary ASCII punctuation:; ^ { } \ [ ~ ] | € ' "— in which case it’s encodedunicode(70 chars/segment). Returns{ segments, encoding, characterCount }.send(to, message, context?)— normalizes recipients to E.164 using the region fromCURRENCY_LOCALE(defaulten-NG); invalid recipients are recorded as failed and are never sent. It resolves config once (SmsCredentialResolverService.resolveConfig()); if the tenant has none configured, throws403 SMS_PROVIDER_NOT_CONFIGUREDbefore attempting anything. It then saves onePENDINGSmsDeliveryLogper recipient with provider and source metadata and enforces the 08:00–19:50 send window inCHURCH_TIMEZONE(defaultAfrica/Lagos) before contacting the provider. An out-of-window attempt is markedFAILEDin the local log and included in the returned outcome. It batchestointo groups of that provider’s ownmaxRecipientsPerRequest. Each batch updates its recipient rows toACCEPTEDorFAILED; provider request errors are returned as{ acceptedCount, failedCount, failures }rather than thrown, allowing the outer tenant transaction to commit diagnostics. Callers surface or log those failures.contextlinks announcement, broadcast, and reminder sends to their source.getLogs()/getBalance()— same resolve-or-403 pattern.getBalance()delegates to the tenant’s provider;getLogs()merges active provider history with local dispatch records and retains local rows if provider history is unavailable. A tenant sees their own vendor’s data, never the platform’s.
Message history (TermiiSmsProvider.getMessageHistory): calls Termii’s GET /api/sms/inbox?api_key=...
(Termii’s documented outbound message-history endpoint; without message_id it returns all account reports) and maps its
raw field names (receiver, message, status, sms_type, message_id, created_at, sender?) to the
provider-agnostic SmsLogEntry shape (recipient, message, status, type, messageId, sentAt, sender?,
provider? — the last set by SmsService.getLogs(), not by the provider class itself).
A non-array response body is treated as empty rather than thrown. SmsService.getLogs() joins provider history to up
to 500 recent local recipient records by (providerMessageId, recipient) and appends unmatched local rows so rejected
requests with no provider message ID remain visible. Log entries expose dispatchStatus, errorMessage, sourceType,
sourceId, sourceLabel, and trackingId; announcement rows can therefore be traced back to their announcement.
TwilioSmsProvider has no native bulk-send
endpoint, so it issues one POST per recipient (Promise.all, capped by maxRecipientsPerRequest) and joins the
returned sids with a comma for messageId.
TermiiSmsProvider uses the configured route credential (generic or dnd), defaulting to generic for existing
and new configurations. Generic is Termii’s promotional route; DND is for transactional/critical messages and must
be enabled by Termii for the workspace. The communication-provider summary returns only the non-secret smsRoute
field so the admin can preserve it while editing credentials. A 422 Route not configured response is explained in
the admin error. Recipients are normalized to E.164 before dispatch, then Termii receives its documented digits-only
international form (no leading +). Successfully Sent means Termii accepted the request; only a later Delivered
provider status confirms delivery to the handset.
Termii documents a 20:00–08:00 restriction for generic-route SMS to MTN; Discuva’s stricter 08:00–19:50 window
applies to all providers and routes.
Routes prefix: /admin/sms (AdminGuard)
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /admin/sms/balance |
SMS_READ | Returns { balance, currency } from the tenant’s active provider — 403 SMS_PROVIDER_NOT_CONFIGURED if none is active |
| POST | /admin/sms/segment-count |
SMS_READ | Body { message } — returns { segments, encoding, characterCount } without sending anything |
| GET | /admin/sms/logs |
SMS_READ | Merged provider history and up to 500 local dispatch rows with send origin, provider IDs, dispatch status, and failure details; frontend filters/paginates the response client-side |
Local tracking table: tenant migration CreateSmsDeliveryLogs creates sms_delivery_logs. It stores each recipient,
message, active provider, provider message ID/status, local dispatch state, provider error, source type/ID/label, and
creation time. Provider failures and out-of-window attempts are retained even if Termii returns no message ID.
Env vars: TERMII_BASE_URL (default https://api.ng.termii.com) — Termii’s API host is infrastructure, not a
secret, so it stays env-driven even under pure BYOK; every tenant’s Termii account (BYOK) talks to the same host.
No platform-default credentials exist for any SMS vendor. See Environment Variables.
Communication Providers (Tenant Self-Service BYOK)
Tenant-facing counterpart to Platform Admin’s read-only/catalog-only communication-provider surface — this is what
lets a church admin actually set their own SMS/email provider credentials (docs/MULTI_TENANT_MIGRATION.md
§Phase 6c’s deferred write side, now built). src/communication-provider/.
Encryption (EncryptionService, src/utility/service/encryption.service.ts): AES-256-GCM, keyed by
CREDENTIALS_ENCRYPTION_KEY (hashed via SHA-256 to a real 32-byte key — same min(32)-chars convention as
JWT_SECRET, no fixed hex/base64 format required on the operator). Each encrypted value is a self-contained
iv:authTag:ciphertext string (all base64) — nothing else needs to be stored alongside it to decrypt later.
encryptFields/decryptFields apply this to every value in a flat credentials object, keeping field names
(apiKey, senderId, etc.) intact and legible in the stored JSONB while no individual value is ever plaintext.
Rotating this key makes every previously-encrypted credential unreadable — there is no re-encryption tooling.
Credential resolution (SmsCredentialResolverService): pure BYOK, no wallet. resolveConfig() looks up the
current tenant’s active TenantCommunicationProviderConfig for the sms channel (cached 300s per
(tenantId, channel), invalidated immediately on write), decrypts it, and returns { providerId, credentials } —
or undefined if the tenant has no active SMS provider configured, which SmsService treats as “can’t send”
(403 SMS_PROVIDER_NOT_CONFIGURED), never as “use a default.”
Email credential resolution (EmailCredentialResolverService): same shape as the SMS resolver, email channel
— resolveConfig() returns { providerId, credentials, senderIdentity } (or undefined for “use platform
default”), cached under the same communication-provider-config:{tenantId}:{channel} key pattern (so
TenantCommunicationProviderService’s existing invalidation already covers this without any changes). No wallet —
email at church-scale volumes is a rounding error on any provider’s free tier, so there’s no cost to meter
(docs/MULTI_TENANT_MIGRATION.md §4.12). providerId matters here in a way it doesn’t for SMS: each provider has an
incompatible credential shape ({user, password[, host, port, secure]} for gmail/smtp, {apiKey} for
resend/sendgrid, {apiKey, domain} for mailgun), so EmailProcessor has to know which concrete
IEmailProvider to hand the decrypted credentials to, not just that BYOK credentials exist. senderIdentity doubles
as the email “from” address for a BYOK tenant (falls back to the platform’s EMAIL_FROM/EMAIL_USER when unset).
Email providers (src/utility/email-provider/): five implemented IEmailProvider classes; only gmail,
resend, and sendgrid are seeded into the communication_providers catalog (smtp/mailgun exist in code but
aren’t tenant-selectable yet — add a catalog row to turn one on) —
providerId |
Class | Credential shape | Platform default env vars |
|---|---|---|---|
gmail |
GmailProvider |
{user, password[, host, port, secure]} |
EMAIL_HOST/EMAIL_PORT/EMAIL_SECURE/EMAIL_SERVICE/EMAIL_USER/EMAIL_PASSWORD |
smtp |
SmtpProvider |
{host, port?, secure?, user, password} |
none — BYOK-only, throws if called without credentials |
resend |
ResendProvider |
{apiKey} |
RESEND_API_KEY |
sendgrid |
SendGridProvider |
{apiKey} |
SENDGRID_API_KEY/SENDGRID_BASE_URL |
mailgun |
MailgunProvider |
{apiKey, domain} |
MAILGUN_API_KEY/MAILGUN_DOMAIN/MAILGUN_BASE_URL |
sendgrid is Twilio’s actual email product (SendGrid) — catalog name SendGrid (Twilio) — registered alongside
twilio (SMS) so a tenant who wants Twilio across both channels can, using each product’s own real credential shape
(they’re genuinely separately-credentialed even though one company owns both, so this is two catalog rows, not one
shared “Twilio” entry).
gmail’s BYOK credentials accept an optional host/port/secure override on top of user/password — this is
what actually lets a tenant route mail through a different domain (Outlook/Office365, Zoho, their own company mail
server) rather than being locked to the platform’s own SMTP settings; omitting them reuses the platform’s own
host/port/secure/service with just a different mailbox. smtp is for a tenant who wants to fully bring their own
server with no platform fallback at all. sendgrid/mailgun call their REST APIs directly via native fetch (no
SDK dependency) — SendGrid with Bearer auth, Mailgun with HTTP Basic auth and a FormData body; both throw a clean
500 if neither BYOK nor platform-default credentials are configured, rather than silently no-op-ing.
Routes prefix: /communication-providers (AdminGuard, tenant-scoped — deliberately not under /platform,
which is entirely excluded from TenantMiddleware)
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /communication-providers |
COMMUNICATION_PROVIDERS_READ | ?channel=sms|email (optional) — returns { catalog, ownConfigs }; ownConfigs never includes credentials |
| PUT | /communication-providers/:channel |
COMMUNICATION_PROVIDERS_WRITE | Body { providerId, senderIdentity?, credentials: Record<string,string> } — upserts this tenant’s config for that channel (always activating it), encrypting credentials before storage |
| PATCH | /communication-providers/:channel/:providerId |
COMMUNICATION_PROVIDERS_WRITE | Body { isActive } — enable/disable an already-configured provider without touching its stored credentials |
Only one active provider per channel: both PUT and PATCH (when activating) run inside a transaction that also
deactivates every other provider already active on that same channel for the tenant
(TenantCommunicationProviderService.deactivateSiblings) — SmsCredentialResolverService/EmailCredentialResolverService
each pick a single isActive = true row per channel, so allowing more than one active at a time would make that
pick arbitrary. Turning a provider off never touches its siblings.
Communication Providers: deactivation has real consequences (added 2026-08). A platform admin can activate/
deactivate a provider in the platform-wide catalog (PATCH /platform/communication-providers/:id,
PlatformCommunicationProviderService.setActive — see Platform Admin above). Initially this only flipped the
CommunicationProvider.isActive column with zero downstream effect anywhere — verified live at the time: neither
credential resolver checked it, the tenant-facing catalog endpoint didn’t filter on it, and a tenant already
configured against a since-deactivated provider kept sending through it exactly as before. Three changes closed
that gap:
TenantCommunicationProviderService.listProviders()excludes an inactive provider from the catalog a tenant can newly select — unless that tenant already has a config against it, in which case it stays visible (filtering it out entirely would make an already-configured provider’s row silently vanish fromdiscuva-admin’s page with no explanation, even though its encrypted credentials are still saved).SmsCredentialResolverService/EmailCredentialResolverServicenow also requireprovider.isActive = truein the same query that already checksconfig.isActive = trueandprovider.channel. A deactivated provider genuinely stops resolving for every tenant using it, not just new ones.PlatformCommunicationProviderService.setActive()invalidates the 300s resolved-credential cache immediately for every tenant with an active config against the provider (communicationProviderCacheKey— extracted as a shared utility,src/communication-provider/utility/communication-provider-cache-key.ts, since four separate places needed the identical cache-key string and three of them were computing it independently before this), rather than leaving affected tenants to keep working for up to 5 more minutes. It also emails those same tenants’ admins —TenantBroadcastService.notifyTenants()(see “Tenant Broadcasts” under Platform Admin above), deliberately targeted at only the tenants actually using this provider, not a platform-wide broadcast — explaining the channel is disrupted (deactivating) or restored (reactivating). A tenant’s ownTenantCommunicationProviderConfigrow is never touched by any of this — same “don’t retroactively delete something already configured” posturesuspendTenantuses for a tenant’s own data.
Env vars: CREDENTIALS_ENCRYPTION_KEY (required, min(32) chars) — see Environment Variables.
Email BYOK send path (EmailProcessor.handleSend, src/utility/processor/email.processor.ts): unlike SMS,
which resolves credentials synchronously within the original request, an email send runs inside a Bull job — tenant
context isn’t ambient there, so handleSend wraps its entire body in runInTenantContext() (previously only
onCompleted/onFailed did this, purely to log) before calling EmailCredentialResolverService.resolveConfig().
Resolves to the concrete IEmailProvider matching the tenant’s providerId if BYOK-configured (falling back to
GmailProvider for gmail or any unrecognized id), otherwise the platform’s constructor-injected
EMAIL_PROVIDER_TOKEN default — source is 'tenant' in the former case, 'platform_default' in the latter.
Which provider/source actually handled a given send is carried back via Bull’s job-return-value convention
(job.returnvalue) so onCompleted logs the real provider/source to EmailLog.provider/EmailLog.source, not
just the platform default — that can differ per send once BYOK is in play. Since handleSend also persists the same
resolved provider/source onto job.data via job.update() before attempting the send, onFailed (which has no
return value to read, since a thrown send means handleSend never reaches its return) can log the actual
provider/source that failed instead of guessing the platform default.
Announcement integration: see “Optional SMS delivery” under Announcements Module — sending SMS on an
announcement requires the SMS_SEND permission (distinct from SMS_READ, which only allows checking balance/cost).
Tenant Profile Self-Service (src/tenant/)
GET /tenant/info (@Public(), still goes through TenantMiddleware) returns branding for the current subdomain —
unchanged. PATCH /tenant/info (AdminGuard, new CHURCH_PROFILE_WRITE permission) is the tenant self-service
write side (docs/MULTI_TENANT_MIGRATION.md §Phase 6c’s deferred item, now built) — lets a church admin edit their
own name/logoUrl/tagline/address/supportEmail/pwaShortName/currency/timezone without going through
platform support, which was previously the only way to change any of it (PATCH /platform/tenants/:id,
platform-admin-only). Body (UpdateTenantProfileDto) is a partial — every field optional, only provided fields are
applied (Object.assign). Deliberately excludes subdomain, schemaName, clusterId, and isActive —
platform-controlled, not something a church admin can change about their own tenant.
pwaShortName (nullable, max 20 chars): the label a member sees under the home-screen icon after installing the
PWA (Android’s manifest short_name, iOS’s apple-mobile-web-app-title) — deliberately separate from name, which
is often too long (formal church names) to survive the ~10-13 characters that render before truncation on a real
home screen. Falls back to name itself when unset (still a real improvement over the platform’s own generic name,
which was the bug this field was added to fix) — see discuva-member’s app/manifest.ts and
context/tenant-context.tsx for where the fallback chain (pwaShortName ?? name) is actually consumed.
platform-admin/dto/update-tenant.dto.ts and PlatformTenantService’s TenantWithHealth/toHealthShape mirror this
field for parity, though no discuva-platform UI currently exposes editing it — self-service via discuva-admin’s
Church Profile page is the only intended write path today.
Church Theme and User Appearance
Tenant.themePreset is the suggested church palette (classic, ocean, forest, sunset, sky, plum, marigold, rose, teal, crimson, indigo, or olive); previousThemePreset stores the last saved preset for one-click revert. It is returned by the
public GET /tenant/info response and can be changed with PATCH /tenant/info by an admin with
CHURCH_PROFILE_WRITE. Existing tenants default to classic; both portals provide matching light and dark token
sets for each preset. previous_theme_preset is added by the root migration AddPreviousTenantThemePreset because
tenants lives in the public control plane; apply it with npm run migration:run, not the tenant-schema runner.
TenantProfileService owns current-tenant lookup, profile persistence, branding-cache invalidation, and palette
auditing. TenantInfoController.updateInfo only forwards the validated DTO and authenticated administrator IDs,
then maps the returned tenant to the existing public profile response.
Successful changes to the church-wide preset emit CHURCH_THEME_CHANGED through the tenant-scoped audit queue.
The entry identifies the administrator’s linked member as actor and the tenant as target; metadata contains
adminId, previousThemePreset, and themePreset. Reverting emits the same event with the reversed values.
Unchanged presets, failed saves, and unrelated church-profile updates do not emit this event. Personal
Light/Dark/System and useChurchTheme preferences are deliberately not audited. Existing audit-log indexes
cover action, actor, target, and creation time; no new schema or index migration is required.
Member.appearanceMode is each account’s system, light, or dark preference (default light) and Member.useChurchTheme is an
independent preference (default true) for applying that tenant’s palette. Members inherit the church’s selected
palette by default and can set the flag to false to keep the standard portal colors. Both fields are included in
member profile responses. Members update either or both through PATCH /members/me/appearance; admins use
PATCH /admin/users/me/appearance. Both routes write the same tenant-scoped Member row, so preferences are shared
if that account uses both portals. Applying a church palette changes brand accents while preserving existing
surfaces and semantic colors. Tenant migration AddMemberAppearanceMode creates the appearance_mode column;
RepairMemberAppearanceMode1800345600000 reasserts it with ADD COLUMN IF NOT EXISTS to repair tenant schemas
where migration history and actual table state have drifted. Tenant migration DefaultMemberAppearanceToLight1800432000000
sets Light as the database default and moves existing System values to Light; users can still explicitly select System
or Dark afterward. The tenant migration AddMemberChurchThemePreference adds the field with a true
default; SetMemberChurchThemeDefault updates existing rows and the database default for tenants that already ran
the original preference migration.
Logo upload (POST /tenant/logo, DELETE /tenant/logo, both CHURCH_PROFILE_WRITE): logoUrl on
PATCH /tenant/info only ever accepted an already-hosted URL — these two routes are the actual upload path, same
shape as MemberController’s POST members/me/photo (DynamicLimitedFileInterceptor, image-mimetype-only filter,
CloudinaryService.uploadBuffer into the church-logos folder) but its own limit —
PlatformSettingKey.MAX_LOGO_UPLOAD_MB (5MB default, platform-admin-configurable), not MAX_AVATAR_UPLOAD_MB —
since a logo is reused across more surfaces than a profile photo and needs more headroom. Tenant.logoPublicId
(new column) tracks
the Cloudinary asset id so a replace or removal can delete the previous asset — deletion always happens after the
new row is saved, so a failed re-upload never leaves a tenant with no logo. All three routes (PATCH /tenant/info,
POST /tenant/logo, DELETE /tenant/logo) return the same profile shape.
Mobile app appearance (tenant_asset_overrides): the member PWA (discuva-member) ships a bundled
default hero/backdrop image for every screen — KNOWN_ASSETS
(src/tenant/constants/known-assets.constant.ts) is the fixed catalog of what can be overridden (25 keys today,
e.g. login-backdrop, home-door-welcome, giving-backdrop, finance-backdrop), each mapped to the screen(s) it actually renders on
in the mobile app. A church can override any of these with its own image; anything left unset silently falls back
to the app’s own bundled default — that fallback resolution happens client-side in discuva-member, not
here. This backend only ever knows what’s been explicitly overridden.
GET /tenant/info’s response gained anassets: Record<assetKey, imageUrl>field — only overridden keys appear in it, never the full catalog and never a default. Bundled into the same call the member app already makes on startup rather than a second round trip.GET /tenant/info’s response includesphoneRegion(ISO 3166 alpha-2, e.g.NG) — the region the API uses to parse local-format phone numbers (fromCURRENCY_LOCALE), so client phone pickers default to the same country the server validates against.GET /tenant/info’s response also gained a plainsubdomain: stringfield — not sensitive (already visible in every discuva-member URL, and the admin types it in at login), added specifically so discuva-admin has a client-side “which tenant am I” signal for its Games presentation-screen fix (see Games Module): that route is deliberately public/unauthenticated (so it can run unattended on a projector) and discuva-admin has no per-tenant subdomain of its own to resolve tenant from the way discuva-member does (single shared host in production; tenant normally comes from the JWT instead), so a public route there has nothing to identify its tenant with unless it’s carried explicitly.GET /tenant/assets/catalog(AdminGuard,CHURCH_PROFILE_WRITE) — the fixedKNOWN_ASSETSlist with labels/descriptions, for the admin appearance-settings page to render without duplicating the catalog client-side.POST /tenant/assets/:key(AdminGuard,CHURCH_PROFILE_WRITE) — upload/replace the override for one asset key. Same upload shape as logo upload (multer,PlatformSettingKey.MAX_LOGO_UPLOAD_MBlimit — 5MB default, image-mimetype-only,CloudinaryService.uploadBuffer, into thetenant-assetsCloudinary folder this time).:keyis validated againstKNOWN_ASSETSinTenantAssetService, not at the DB level, so the catalog can grow without a migration. Same “new asset saved before the old one is deleted” ordering as logo upload.DELETE /tenant/assets/:key(AdminGuard,CHURCH_PROFILE_WRITE) — removes the override row and the Cloudinary asset, reverting that screen to the app’s bundled default. A no-op (still200, still deletes nothing) if no override existed for that key.
discuva-member: public-link routes vs. “the app” (components/pwa/standalone-gate.tsx). PUBLIC_LINK_ROUTE_PREFIXES
(/forms/public/, /classes/guest/, /p/) is the fixed list of routes explicitly designed to be reachable by
anyone with the link — no account, no installed app (a QR-scanned public form, a guest class portal, a public
page). StandaloneGate (mounted above the whole app in app/layout.tsx) already exempts these from the
“install this app first” wall for exactly that reason. UpdateBanner (same layout, “a new version of the app
is ready”) had the identical gap and no exemption at all — reported live: it makes no sense to someone who just
opened a shared link and was never “in the app” to begin with. Fixed by exporting the same prefix check
(isPublicLinkRoute) from standalone-gate.tsx and reusing it in update-banner.tsx, rather than maintaining
a second list that could quietly drift out of sync with the first.
TenantAssetOverride lives in public (tenant_asset_overrides, FK to tenants.id ON DELETE CASCADE), not a
per-tenant schema — this is the same category of data as Tenant.logoUrl (self-service branding a church sets
once and rarely touches), not operational data needing schema isolation. One row per (tenant, assetKey),
enforced with a unique constraint.
The admin-side crop tool (discuva-admin’s Appearance settings page) guides toward each asset’s actual render
ratio before upload, but nothing here enforces it server-side — every one of these images renders with
object-cover inside a fixed-size container in the member app, so a mismatched upload crops awkwardly rather than
breaking layout.
Billing & Checkout (src/billing/)
Tenant self-service surface for the plan/subscription infrastructure described in
docs/MULTI_TENANT_MIGRATION.md §4.11/§9 Phase 3 — view current plan/subscription status and initiate a Paystack or
Flutterwave checkout to upgrade the plan. PlanGuard itself doesn’t depend on any of this working — a tenant can
always be moved onto Pro manually via the platform-admin escape hatch (PATCH /platform/tenants/:id/plan); this
module is what lets a tenant do it themselves, and pay for it. (SMS billing lives entirely outside this module now —
see SMS Module above for why.)
Three payment providers, registered simultaneously (PaymentProviderRegistryService) — unlike email, where one
platform-default concrete class is chosen once at boot (SMS has no platform default at all, pure BYOK — see SMS
Module above), PaystackPaymentProvider, FlutterwavePaymentProvider, and KoraPaymentProvider are always
available; a checkout call picks one by name (?provider=paystack/flutterwave/kora in the request body),
defaulting to DEFAULT_PAYMENT_PROVIDER when unspecified. All three are platform-wide credentials, not tenant
BYOK — unlike SMS/email/YouTube, these charges pay the platform (plan upgrades), so the merchant keys have to be
the platform’s own, never a tenant’s. This is the platform-billing counterpart to the tenant-facing, fully-BYOK
giving/tithe system (src/giving-checkout/, which also supports Kora — KoraGivingProvider — plus Stripe); the two
systems share no config or code, only the same proven Korapay request/webhook-signing shape.
Plan.currency is validated against SUPPORTED_BILLING_CURRENCIES (src/billing/constant/supported-currencies.constant.ts), currently ['NGN', 'USD'] only. Nothing in PaymentProviderRegistryService or any of the three provider implementations checks that Discuva’s own merchant account for the chosen provider can actually settle in a plan’s currency — a plan created with an unsupported currency would only fail at charge time, at the provider, not at plan-creation time. CreatePlanDto/UpdatePlanDto enforce this whitelist so a currency can’t be picked (via discuva-platform’s Plan form or a direct API call) without first confirming it with each active provider’s Discuva-owned account and widening the constant.
Multi-currency, multi-interval tiers (Plan.tierKey, Plan.billingInterval): each Plan row is still exactly one immutable priced offering in one currency and one billing interval — id remains the real billing identity (Subscription.planId, Plan.billingProviderPriceId all key off it, untouched by anything below). tierKey is a separate, purely-display grouping key that lets multiple rows represent the same conceptual tier across currency and/or interval — e.g. pro (NGN, monthly), pro-usd (USD, monthly), pro-annual (NGN, annual) and pro-usd-annual (USD, annual) all share tierKey: 'pro', four independent rows, each a real, deliberately-priced offering (never a currency conversion or a computed 12x-minus-discount of another). PlanGuard/PlanFeatureResolverService/checkout are entirely unaffected — they resolve via Subscription.planId → Plan, never tierKey. tierKey and billingInterval ('monthly' | 'annual', BillingInterval enum) are both required on POST /platform/plans and optional on PATCH /platform/plans/:id. Safeguard: PlatformPlanService.updatePlan() rejects (400) a currency or billingInterval change once Plan.billingProviderPriceId is already set, since the cached provider-side price/interval object would silently keep charging at the old currency/cadence — create a new plan variant row instead of editing an existing plan’s currency or interval.
Interval-aware period extension: CheckoutService.applyChargeSucceeded() looks up the charged Plan’s billingInterval and extends Subscription.currentPeriodEnd by SUBSCRIPTION_PERIOD_DAYS (monthly, default 30) or ANNUAL_SUBSCRIPTION_PERIOD_DAYS (annual, default 365) accordingly — both a fresh checkout and (for Paystack, see below) a provider’s own renewal charge.succeeded go through this same path, so an annual charge genuinely grants ~365 days, not 30. Only Paystack’s lazily-created provider-side Plan object is told this interval at all (interval: 'annually' for an annual Plan, Paystack’s own documented value, mapped from our BillingInterval.ANNUAL) — see the Flutterwave/Kora capability-gap note below for why Flutterwave never receives one. PlatformAnalyticsService.mrrByCurrency() normalizes an annual subscriber’s price to a monthly-equivalent (÷12) before summing, so “MRR” stays actually monthly rather than overstating annual subscribers ~12x.
Currency unit mismatch between the providers, handled internally: Paystack’s Initialize Transaction takes
amount in the currency’s smallest unit (kobo for NGN) — matches this codebase’s existing priceCents/amountCents
convention, no conversion needed. Flutterwave’s Standard Payment and Korapay’s Initialize Charge both take the
major unit (naira) — every amount is divided by 100 before being sent and multiplied back where relevant. Only
Paystack lazily creates (and persists onto Plan.billingProviderPriceId) a matching provider-side plan object the
first time a planId is checked out against — Flutterwave and Kora never do, see below.
Neither Kora nor Flutterwave has a working recurring-subscription mechanism — a real capability gap, documented
rather than papered over. Korapay has no confirmed subscription/plan product at all; KoraPaymentProvider never
claimed one. Flutterwave does have a documented payment-plans + payment_plan API and this codebase originally
used it the same way Paystack uses its plan object — but a real Paystack-style sandbox test exposed that it
doesn’t reliably work: a successful Flutterwave subscription checkout came back with paymentPlan: null on its
charge.completed webhook. The channel the customer paid with was USSD ("event.type": "USSD_TRANSACTION" in that
payload) — only a card payment is actually re-chargeable later, and Flutterwave’s hosted checkout offers whichever
channels are enabled on the account with no way from this codebase to restrict a subscription checkout to card
only. Rather than depend on the customer happening to pick a channel that supports it, FlutterwavePaymentProvider
was changed to match KoraPaymentProvider exactly: createSubscriptionCheckout is a single charge for the plan’s
price (no payment_plan attached), not an auto-renewing subscription, and Plan.billingProviderPriceId is never
set by either provider. Concretely: a tenant on Paystack may be silently re-charged by Paystack’s own recurring
engine when their period ends (see SubscriptionLapseScheduler below); a tenant on Flutterwave or Kora never will
be — they always fall through to the normal failed-renewal flow (PAST_DUE email → grace period → downgrade) and
must complete a fresh checkout to renew. Both FlutterwavePaymentProvider.cancelSubscription and
KoraPaymentProvider.cancelSubscription are correspondingly documented no-ops (nothing server-side to cancel).
Kora’s refund() throws rather than calling an unverified endpoint — Korapay’s real refund API shape hasn’t been
confirmed against sandbox behavior the way /charges/initialize and its webhook signing have (proven first in
KoraGivingProvider); Flutterwave’s refund endpoint (unrelated to the payment-plan gap above) has been verified.
Don’t set DEFAULT_PAYMENT_PROVIDER to kora or flutterwave without accounting for the lack of real
auto-renewal.
BillingCheckoutSession (public.billing_checkout_sessions) is recorded at checkout-initiation time, primary-
keyed by the provider’s own reference (Paystack reference / Flutterwave tx_ref) — this is the only thing a
webhook payload is ever trusted for identity/amount against. CheckoutService.handleWebhookEvent() looks up this
row by the reference the webhook echoes back; a reference with no matching pending row (unknown, already
processed, or forged) is a safe no-op, never an error that could imply something was charged. One intent today:
subscription (activates a period on Subscription — see SUBSCRIPTION_PERIOD_DAYS/ANNUAL_SUBSCRIPTION_PERIOD_DAYS — not true
provider-driven recurring-billing reconciliation, deferred pending live sandbox testing). A wallet_topup intent
existed pre-BYOK (funded a prepaid SmsWallet debited per SMS sent) — removed along with the wallet itself once SMS
went pure BYOK (§ SMS Module); BillingCheckoutType only has SUBSCRIPTION now.
Self-serve cancel/downgrade (CheckoutService.cancelSubscription): the tenant-facing counterpart to the
platform-admin escape hatch. Still within a paid period (currentPeriodEnd in the future): sets
Subscription.cancelAtPeriodEnd = true and the tenant keeps their plan’s features until that date —
SubscriptionLapseScheduler (below) completes the downgrade once it passes, rather than yanking access from a
period they already paid for. No active period left: downgrades immediately. Best-effort calls the provider’s own
cancelSubscription() first (via billingProviderSubscriptionId), but a provider API failure never blocks the
local downgrade — the tenant’s stated intent to stop wins regardless. Throws 400 if the tenant has no paid
subscription, or if the plan is sponsored by a parent tenant (see Branch Hierarchy below — a sponsored plan isn’t
the branch’s own to cancel).
Failed-renewal safety net (SubscriptionLapseScheduler, daily 04:00, distributed-lock guarded): finds every
ACTIVE subscription whose currentPeriodEnd has passed with no new charge.succeeded webhook extending it.
Backed by a composite (status, current_period_end) index (idx_subscriptions_status_current_period_end,
AddSubscriptionStatusPeriodEndIndex migration) — the query is WHERE status = 'active' AND current_period_end < now(), and since ACTIVE is presumably the majority status platform-wide, a single-column status index (the old
idx_subscriptions_status, dropped in the same migration as redundant — the composite’s leftmost prefix already
covers a status-only filter) barely narrowed the scan on its own.
cancelAtPeriodEnd = true (a voluntary cancellation reaching its natural end) downgrades immediately, no drama. Any
other lapse is treated as a failed renewal: flips status to PAST_DUE (the “queryable payment-status field” the
frontend can key a banner off — SubscriptionStatus.PAST_DUE existed as an enum value long before anything actually
set it), emails the tenant’s oldest active admin, and gives a GRACE_PERIOD_DAYS (7) window before finally
downgrading to Free. Known limitation, documented rather than silently accepted: Subscription.billingProviderSubscriptionId
capture is wired up for Paystack (subscription.create, verified against a real sandbox payload, see below) — a
Paystack tenant whose subscription is canceled from Paystack’s own hosted portal is now recognized as canceled
immediately rather than only once they lapse here. This doesn’t apply to Flutterwave or Kora at all, but not
because anything is unwired — neither provider ever creates a real server-side subscription in the first place
(see the capability-gap note above), so there’s no provider-side cancellation event to miss; a Flutterwave/Kora
tenant’s renewal is always self-serve, and this scheduler’s PAST_DUE → grace period → downgrade flow is the
expected path for them, not a gap. “Retrying” a failing card
is the provider’s own responsibility (both Paystack and Flutterwave retry several times before giving up, well
within the 7-day window) — this scheduler only reflects local state, it never re-attempts a charge itself.
Branch plan sponsorship (Subscription.sponsoredByTenantId): set when a branch’s plan was comped by its parent
at invite time rather than paid independently — see Branch Hierarchy below. Deliberately excluded from
PlatformAnalyticsService’s MRR calculation (no real money backs it) and blocks the branch’s own admin from
self-cancelling it (that’s the parent’s call, via the invite/hierarchy relationship, not a POST /billing/cancel
on a plan they don’t actually pay for).
Refunds (platform-admin only, not tenant-facing): IPaymentProvider.refund(providerReference, amountCents?) —
Paystack refunds by transaction reference directly; Flutterwave requires resolving the reference to its own numeric
transaction id first (GET /transactions?tx_ref=), handled internally. Kora’s refund() throws unconditionally —
not implemented against a verified endpoint (see above) — so refundCheckoutSession() on a Kora-paid session fails
loudly rather than silently no-op’ing; refund a Kora charge directly in the Korapay dashboard instead until this is
built. CheckoutService.refundCheckoutSession()
only allows refunding a completed session, marks it BillingCheckoutStatus.REFUNDED, and deliberately does
not automatically downgrade a plan — that requires a product decision (does downgrading strand data created on the
paid tier?) this pass doesn’t take on. A platform admin issuing a refund is expected to also apply the tenant-facing
consequence manually via the existing escape hatch if warranted.
Payment Providers: deactivation has real consequences (added 2026-08, same pass as Communication/Giving
Providers’ equivalents). payment_providers (PlatformPaymentProvider) gives platform admins the same
list/deactivate capability over paystack/flutterwave/kora that already existed for communication and giving
providers — but the blast radius is meaningfully narrower here, because unlike those two there’s no per-tenant BYOK
config table: every tenant shares the platform’s own provider credentials, so there’s nothing to filter out of a
tenant-facing catalog and nothing per-tenant to cache-invalidate.
PaymentProviderRegistryService.get()— used by webhook handling (handleWebhookEvent), self-serve cancel (cancelSubscription), and refunds (refundCheckoutSession) — deliberately never checks the DBisActiveflag. An already-charged or already-subscribed tenant’s in-flight lifecycle must keep working regardless of a later deactivation; rejecting a webhook for an already-completed charge would take the tenant’s money without crediting their subscription, the same reasoningGivingCheckoutService.handleWebhookalready established for tithe/giving webhooks.PaymentProviderRegistryService.assertActive()— a new, separate method, used only byinitiateSubscriptionCheckout()— resolves the same provider name then throws400if itspayment_providersrow is deactivated. This is the only place deactivation is actually enforced: starting a new subscription checkout against a deactivated provider.PlatformPaymentProviderService.setActive()looks up everySubscriptioncurrently on that provider (any status exceptCANCELED) and sends a targetedTenantBroadcastService.notifyTenants()email — worded accurately rather than reusing the Communication/Giving copy verbatim, since an existing subscriber’s recurring renewal is genuinely unaffected (it flows through the webhook path above, which never checksisActive); only starting a new checkout with that provider is blocked until it’s restored.- No
registerProvider()— unlikeCommunicationProvider/GivingProvider, paystack/flutterwave/kora are hard-codedIPaymentProviderclasses wired intoBillingModule(PaystackPaymentProvideretc.), not arbitrary BYOK entries a platform admin can add by id/name alone. A fourth vendor needs its own provider class written and registered inPaymentProviderRegistryServicefirst, same as it always has — the DB row is just bookkeeping for that vendor’s on/off state, not a way to add one.
Routes (AdminGuard, tenant-scoped, unless noted):
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /billing/summary |
BILLING_READ | { planId, planName, subscriptionStatus, currentPeriodEnd, cancelAtPeriodEnd, sponsoredByParent } |
| GET | /billing/providers |
BILLING_READ | Payment providers a church can pick at plan checkout: [{ id, name }] — active, registered and configured only |
| GET | /billing/plans |
BILLING_READ | Full plan catalog ([{ id, name, tierKey, priceCents, currency, features }]), ordered by price ascending — every currency variant of every tier as its own row; the frontend groups by tierKey itself. The only tenant-accessible plan list; GET /platform/plans is platform-admin-only |
| GET | /billing/public/plans |
None — @Public() (bypasses the global JwtAuthGuard) and TenantMiddleware-excluded |
Tier-grouped catalog for discuva-web (no tenant/admin context at all): [{ tierKey, name, features, featureLimits, variants: [{ planId, currency, priceCents, billingInterval }] }], variants and tiers sorted by price ascending |
| POST | /billing/checkout/subscribe |
BILLING_WRITE | Body { planId, provider?, successUrl, cancelUrl } — returns { checkoutUrl } to redirect the admin to; 400 if the named (or default) provider is deactivated — see “Payment Providers: deactivation has real consequences” above |
| POST | /billing/cancel |
BILLING_WRITE | No body — cancels immediately or at period end depending on currentPeriodEnd; 400 if no paid/cancelable subscription |
| POST | /webhooks/billing |
No guard — provider webhook | @Public(), dispatches to Paystack or Flutterwave by which of their two signature headers is present (x-paystack-signature HMAC-SHA512 vs verif-hash shared-secret string compare) |
| GET | /platform/tenants/:id/billing-sessions |
Platform admin | This tenant’s checkout session history, newest first |
| POST | /platform/billing-sessions/:sessionId/refund |
Platform admin | Body { amountCents? } — omitted means a full refund |
| GET | /platform/payment-providers |
Platform admin (BILLING_READ) |
[{ id, name, isActive }], ordered by name |
| PATCH | /platform/payment-providers/:id |
Platform admin (BILLING_WRITE) |
{ isActive } — activate/deactivate. See “Payment Providers: deactivation has real consequences” above. |
Monnify (Moniepoint) for platform billing (added 2026-09-30): MonnifyPaymentProvider, charging Discuva’s own
Monnify account (MONNIFY_API_KEY, MONNIFY_SECRET_KEY, MONNIFY_CONTRACT_CODE; MK_TEST_ keys use Monnify’s
sandbox). Shares MonnifyApi (src/utility/monnify/monnify-api.ts — sign-in token cache, init-transaction,
signature check) with the giving provider. Same limits as Korapay: no recurring-plan product, so a subscription is one
charge for the plan’s price and renews through the normal lapse/checkout flow; cancelSubscription is a no-op;
refund throws (refund in the Monnify dashboard). Webhooks arrive on the shared POST /v1/webhooks/billing route,
dispatched by the monnify-signature header. Only a PAID SUCCESSFUL_TRANSACTION activates a plan; PARTIALLY_PAID /
OVERPAID are logged and left pending for the platform team. Seeded inactive (root migration
AddMonnifyPlatformPaymentProvider) — set the keys, then switch it on in the platform portal.
Which providers churches see: GET /billing/providers (BILLING_READ) returns [{ id, name }] for providers
that are active in payment_providers, registered, and have their key set (PAYSTACK_SECRET_KEY,
FLUTTERWAVE_SECRET_KEY, KORA_SECRET_KEY, MONNIFY_API_KEY). The church admin’s billing page offers
Paystack/Flutterwave/Monnify from that list (Korapay is still not offered there); if the endpoint is missing it falls
back to Paystack and Flutterwave.
Env vars: PAYSTACK_SECRET_KEY, PAYSTACK_BASE_URL, FLUTTERWAVE_SECRET_KEY, FLUTTERWAVE_SECRET_HASH,
FLUTTERWAVE_BASE_URL, MONNIFY_API_KEY, MONNIFY_SECRET_KEY, MONNIFY_CONTRACT_CODE, DEFAULT_PAYMENT_PROVIDER, SUBSCRIPTION_PERIOD_DAYS (default
30, monthly-plan renewal period), ANNUAL_SUBSCRIPTION_PERIOD_DAYS (default 365, annual-plan renewal
period — both read by CheckoutService.applyChargeSucceeded(), keyed by the charged plan’s billingInterval),
GRACE_PERIOD_DAYS (default 7, SubscriptionLapseScheduler’s
PAST_DUE window before downgrading to Free) — see Environment Variables.
Paystack subscription.create handling (added and verified against a real sandbox payload):
CheckoutService.applySubscriptionCreated(), triggered by PaymentEventType.subscription.created, fires once
right after the first successful charge on a subscription-linked transaction — confirmed live that charge.success
itself never carries a subscription identifier, only this separate event does (data.subscription_code). Matched
to a tenant via data.customer.metadata.tenantId (the same metadata attached at checkout-initiation time), not a
checkout reference, since a freshly-created provider subscription has none of its own. Populates
Subscription.billingProviderSubscriptionId (closing the gap applySubscriptionCanceled needed — see above) and,
when the payload includes one, sets currentPeriodEnd directly from the provider’s own next_payment_date rather
than our SUBSCRIPTION_PERIOD_DAYS/ANNUAL_SUBSCRIPTION_PERIOD_DAYS math, since that reflects the provider’s
actual billing clock rather than whenever we happened to receive a webhook. No Flutterwave/Kora equivalent, and
none is planned — neither provider ever creates a real server-side subscription (see the capability-gap note
above), so there’s no creation event to capture an id or a next-payment-date from; their currentPeriodEnd is
always purely SUBSCRIPTION_PERIOD_DAYS/ANNUAL_SUBSCRIPTION_PERIOD_DAYS math from checkout time, by design. True
full recurring-billing reconciliation (the provider’s own renewal events driving every subsequent period, not just
the first) is still not built even for Paystack — only the first charge’s subscription-creation metadata is
captured today.
Billing/plan settings UI in discuva-admin (/billing) is built — plan picker, cancel/downgrade, past-due banner,
plan-inheritance indicator for a sponsored branch. (SMS wallet top-up UI was removed along with the wallet itself —
SMS billing is now entirely the tenant’s own vendor relationship, outside this app.)
Plan feature gating (PlanGuard) — boolean gate plus an optional, per-route numeric cap: any route decorated
@RequiresPlan(PlanFeature.X) first checks Plan.features membership (boolean gate, cached under
plan-features:${tenantId} for 300s via the shared PlanFeatureResolverService) and throws
403 { code: 'PLAN_UPGRADE_REQUIRED' } if the feature isn’t included. If the feature is included, the plan has a
numeric limit configured for it (Plan.featureLimits, a jsonb map of capability key → max lifetime uses,
admin-editable via PATCH /platform/plans/:id), and the specific handler invoked also carries
@CountsTowardLimit(PlanFeature.X), PlanGuard does a read-only check (FeatureUsageService.getUsage) and 403s
if usage is already at the cap. This is deliberately narrower than the boolean gate above: @RequiresPlan sits at
class level and covers every route in a controller (list, read, poll, join…), while @CountsTowardLimit is
opt-in per method — only the one route meant to consume a use (typically create) carries it, e.g.
AdminGameController.create is the only Games route with @CountsTowardLimit(PlanFeature.GAMES); listing games,
polling a live session’s state, joining, answering, and viewing a leaderboard never touch the counter even when a
games limit is configured.
The actual increment happens in PlanLimitInterceptor (src/billing/interceptor/plan-limit.interceptor.ts,
registered globally via APP_INTERCEPTOR in billing.module.ts, a no-op unless the route carries
@CountsTowardLimit), after the handler succeeds — via RxJS tap() on the response — not before. A request
whose handler throws (validation error, 404, etc.) never reaches the tap(), so a failed create never spends a
use; only a request that actually completes does. The increment itself reuses FeatureUsageService.tryConsume
(backed by public.feature_usages, one row per (tenantId, feature), a single conditional
INSERT ... ON CONFLICT ... WHERE count < limit), fire-and-forget from the interceptor’s point of view — the
response has already been decided by the guard’s earlier read-only check. Splitting “check” (guard, before) from
“consume” (interceptor, after success) reopens a narrow race two truly concurrent creates could both pass the
pre-check before either increments; tryConsume’s own WHERE count < limit still caps the damage to at most one
extra unit of usage, an accepted tradeoff over the alternative of consuming on every request regardless of outcome.
Usage counts are lifetime and never reset by a plan change — upgrading past a cap and later downgrading back below
it still reflects prior usage rather than granting a fresh allowance.
Every toggleable module is also a plan-assignable capability, not just the original 12 PlanFeature values.
ModuleEnabledGuard (src/church-settings/guard/module-enabled.guard.ts) — the guard behind every
@RequiresModule('x') controller — checks two things in order: the tenant’s ChurchSetting on/off toggle
(unchanged), then whether x is included in the tenant’s plan’s features array (same PlanFeatureResolverService
lookup PlanGuard uses), 403ing with the identical PLAN_UPGRADE_REQUIRED shape if not. This makes moving any
module (originally Prayer, Evangelism, Training Classes, Tithe/Giving, Sunday School, Pastor Feedback, Fellowships,
Social Media, Children’s Church, Announcements, Follow-Up — the 11 that were previously free with no plan concept
at all) between Free and Pro a PATCH /platform/plans/:id data change from the discuva-platform Plans page, not a code
deploy — no new PlanFeature enum value, no new @RequiresPlan decorator, no migration. ALL_CAPABILITY_KEYS
(src/billing/constant/capability-keys.constant.ts) is the full set of strings a features/featureLimits entry
may validly be: the original PlanFeature values (finance/sms/audit/bulk_export have no KNOWN_MODULES
counterpart and stay purely plan-gated, no toggle) unioned with every KNOWN_MODULES key. GET /platform/capabilities
(PlatformCapabilityService) returns this same set labeled for the Plans page’s checkbox list, replacing what used
to be a hardcoded 12-entry array in plan-form-panel.tsx (which — notably — never included forms, fixed as a
side effect).
One key per module (fixed 2026-09-29): PlanFeature.SERMON, SERVICE_RATING and VOLUNTEER used to be sermon,
service_rating and volunteer while their modules used sermons, service_ratings and volunteering. Access
needed both keys, but the Plans page only listed the module keys, so platform admins couldn’t grant these to a plan,
and removing one from Pro only blocked the member side (the admin controllers check @RequiresPlan alone). The enum
now uses the module keys, and root migration UnifySermonRatingVolunteerPlanKeys renames the old keys in
plans.features, plans.feature_limits and tenants.module_overrides. PlanGuard also honours
Tenant.moduleOverrides now (false blocks, true grants, checked before plan membership) — the same precedence as
ModuleEnabledGuard — so a per-church override works on every route.
A one-time backfill migration (BackfillModuleCapabilityKeys) added the 11 previously-free module keys to both
free and pro plans’ features (preserving today’s access for every tenant — a platform admin removes a key
from free afterward to make it Pro-only) and 3 module-key spellings that don’t match their pre-existing
PlanFeature value (sermons/sermon, service_ratings/service_rating, volunteering/volunteer — these
three already carried both decorators with two different strings) to pro only, alongside the existing spelling —
a known, deliberately-left naming inconsistency, not something worth a tenant-data rename for now.
Tithe/Giving (tithe key — manual recording, BYOK payment-provider setup, and the member checkout flow) is
Pro-only (MoveTitheToProOnly migration, run right after the backfill above). Same treatment as sms: BYOK
means it costs Discuva nothing regardless of volume (money flows straight through the tenant’s own Paystack/
Flutterwave/Stripe/Kora account, TenantGivingProviderController), but it’s high enough business value that it’s
gated as a deliberate upgrade lever rather than left free on cost grounds. The remaining 10 free-from-launch
modules (Prayer, Evangelism, Training Classes, Sunday School, Pastor Feedback, Fellowships, Social Media,
Children’s Church, Announcements, Follow-Up) are unaffected.
Per-tenant manual override, independent of plan (Tenant.moduleOverrides). The plan-features mechanism above
answers “which tier includes this module,” a platform-wide default every tenant on that tier shares. It has no
answer for “let this one specific church test it regardless of their plan” or “pull access from this one tenant
without touching their plan or anyone else’s” — exactly the shape of control needed to roll a still-unstable module
(Social Media, initially) out to a hand-picked set of test tenants before it’s ready to sit in any plan’s default
features at all. moduleOverrides is a nullable jsonb map on Tenant, keyed by the same KNOWN_MODULES/
PlanFeature strings as Plan.features — { social_media: true } grants that module regardless of plan,
{ social_media: false } blocks it regardless of plan, a key simply absent (or the whole map null) means no
override — falls through to the plan check exactly as before this existed. ModuleEnabledGuard checks it between
the tenant’s own on/off toggle and the plan-features check: tenant toggle off still always wins (a church’s own
choice is never overridden), then override false blocks outright, override true grants outright, and only an
absent override falls through to features.includes(moduleKey). PlanGuard applies the same override precedence
(since 2026-09-29), so a module whose routes also carry @RequiresPlan — Sermons, Service Ratings, Volunteering, Forms,
etc. — honours the override on every route. setModuleOverride() still only accepts KNOWN_MODULES keys, so the
plan-only features (finance, sms, audit, bulk_export, notification_customization) can’t be overridden per
church from the Tenant edit UI.
PlanFeatureResolverService.resolve() — already shared by both guards, already caching per-tenant under
plan-features:${tenantId} — now also fetches the Tenant row and returns overrides alongside features/
featureLimits, so this costs no extra guard-level round trip or cache key. PlatformTenantService.setModuleOverride()
validates moduleKey against KNOWN_MODULES, merges into the existing map (clearing to null only once the
last override key is removed, never wiping a tenant’s other overrides), saves, and invalidates the same
plan-features:${tenantId} cache key changeTenantPlan/applyDiscount already do.
| Method | Route | Auth | Notes |
|---|---|---|---|
| PATCH | /platform/tenants/:id/module-overrides |
PlatformAdminGuard (TENANTS_WRITE) | {moduleKey: string, enabled: boolean | null} — null clears just that one key |
MakeSocialMediaOverrideOnly migration removed social_media from every plan’s features (it had been in
pro/pro-annual/pro-usd/pro-usd-annual since the original backfill, never in free) — Social Media is no
longer plan-included by default anywhere. It’s an opt-in, still-early-access module now gated entirely through the
Social Media Rollout control below.
Social Media Rollout — the one control surface (PlatformTenantService.setSocialMediaRollout/
getSocialMediaRollout). A platform admin doesn’t reason about Plan.features vs Tenant.moduleOverrides
separately for this module — they use a single toggle plus an optional searchable multi-select of churches, and
setSocialMediaRollout() decides which underlying mechanism to write:
- Disabled: strips
social_mediafrom every plan’sfeaturesand clears thesocial_mediakey from every tenant’smoduleOverrides. Nobody has access. - Enabled, empty selection (“everyone”): adds
social_mediato every plan’sfeatures(all tiers, not just Pro — a true “for all” regardless of plan) and clears every tenant’s override. Forward-looking: a tenant created next week is covered automatically via the plan check, same as any other plan-included module. - Enabled, specific selection: strips
social_mediafrom every plan’sfeatures(so it stays off by default) and setsmoduleOverrides.social_media = truefor exactly the selected tenants — clearing the key for any previously-selected tenant no longer in the list, so re-saving a shorter list actually revokes access rather than only ever adding to it.
The two mechanisms are kept mutually exclusive on write so there’s never a redundant or contradictory state (a
tenant with a true override while the plan already includes the module, or vice versa).
| Method | Route | Auth | Notes |
|---|---|---|---|
| GET | /platform/social-media/rollout |
PlatformAdminGuard (SOCIAL_MEDIA_APPS_READ) | {enabled: boolean, tenantIds: string[]} — derived live: enabled:true, tenantIds:[] if any plan includes social_media, else the list of tenants with a true override |
| PUT | /platform/social-media/rollout |
PlatformAdminGuard (SOCIAL_MEDIA_APPS_WRITE) | {enabled: boolean, tenantIds: string[]} — full replace, not incremental |
Frontend: a “Social Media Rollout” card on discuva-platform’s Social Media Apps page (RolloutPanel) — an
on/off switch plus a searchable multi-select of churches (chips + type-ahead), shown only when enabled. Replaces
the earlier per-tenant TenantDetailPanel “Force On/Off” buttons, which required visiting each church individually
and made “roll out to everyone” a two-step, easy-to-forget dance (remove from Plan.features, then Force On each
tenant by hand) — this is now one screen, one save. TenantDetailPanel’s “Social Media Access” field is now
read-only status text (resolved from the same moduleOverrides/plan data) with a link back to this page; the
generic PATCH /platform/tenants/:id/module-overrides endpoint and setModuleOverride() still exist underneath
and remain usable for any other KNOWN_MODULES key that later needs the same per-tenant-override treatment.
Pages Rollout — same mechanism, generalized. setSocialMediaRollout/getSocialMediaRollout were the only
implementation of this rollout lifecycle until Pages needed the identical “opt-in, still-early-access module with
no platform-admin control surface at all” treatment the Pages section above already flags (it shipped gated purely
through Tenant.moduleOverrides, with no dedicated rollout UI, requiring a platform admin to Force On each church
individually via the generic module-overrides endpoint). Rather than duplicate the whole all-vs-specific/
Plan.features-vs-moduleOverrides branch a second time, that logic moved into private
PlatformTenantService.setModuleRollout(moduleKey, dto)/getModuleRollout(moduleKey), with
setSocialMediaRollout/getSocialMediaRollout now one-line delegates (moduleKey: 'social_media') and two new
public delegates, setPagesRollout/getPagesRollout (moduleKey: 'pages'), added alongside them. The request DTO
was renamed accordingly (set-social-media-rollout.dto.ts → set-module-rollout.dto.ts,
SetSocialMediaRolloutDto → SetModuleRolloutDto) since its shape was already module-agnostic. Behavior is
byte-identical to Social Media’s, just keyed on pages instead — a plan gaining pages in its features (the
“everyone” case) or a tenant gaining moduleOverrides.pages = true (the “specific churches” case) both flow
through the exact code path already covered above.
Two bugs found and fixed while wiring up Pages Rollout, both around cache invalidation from platform-admin requests. Reported symptom: a platform admin enabled Pages for a tenant, but that tenant’s discuva-admin kept showing the plan-upgrade-required gate.
-
Missing invalidation entirely, in
setSocialMediaRollout’s “disabled” and “enabled, empty tenantIds” branches.PlanFeatureResolverService.resolve(tenantId)caches its result underplan-features:${tenantId}for 300s. Those two branches only calledcacheService.del()for tenants whose override changed in the loop that follows them — a tenant gaining or losing access purely because the plan’sfeaturesarray changed (the common case for both branches, and for any plainPATCH /platform/plans/:idedit to a plan’sfeaturesvia the Plans admin page) had no cache invalidated at all. Fixed two places:setModuleRollout()now calls a newinvalidateAllTenantCaches()(a full tenant scan + one cache invalidation per tenant — no reverse index exists from a plan to its subscribers, and tenant counts at this platform’s scale make a full scan cheap enough not to warrant one) at the end of all three branches, superseding the old per-affected-tenant-only invalidation;PlatformPlanService.updatePlan()(previously had noCacheServicedependency at all) now invalidates every tenant subscribed to the edited plan wheneverfeaturesorfeatureLimitschanges. -
The deeper bug: every invalidation in this file — including the pre-existing ones in
changeTenantPlanandsetModuleOverridethat predate this Pages work entirely — was computing the wrong Redis key.CacheService.del()/.get()/.set()all route through a privatescopedKey()that prefixes the key withtenant:${cls.get('tenantId') ?? 'global'}:. Platform-admin routes are deliberately excluded fromTenantMiddleware(seePlanFeatureResolverService’s own comment), so every request intoPlatformTenantService/PlatformPlanServicehas no tenant id in CLS at all — a barecacheService.del(\plan-features:${tenant.id}`)call from here always resolved totenant:global:plan-features:${tenant.id}, while the entry actually written byPlanFeatureResolverService.resolve()(called from inside an actual tenant-scoped request, where CLS genuinely holds that tenant's id) lives attenant:${tenant.id}:plan-features:${tenant.id}— a different key entirely, so the "invalidation" was always a silent no-op. This had gone unnoticed for as long as caching has existed here (tenant-brandingcache invalidation inupdateTenanthas the identical bug) because the TTL is only 300s — most manual testing outlasted the staleness window without anyone noticing the del() call never actually did anything. It surfaced clearly this time because the Pages tenant under test had a warm cache from moments earlier. **Fix:** a newPlatformTenantService.delTenantCache(tenantId, key)re-enters that tenant's CLS context viacls.runWith({tenantId}, () => cacheService.del(key))before deleting — every cache invalidation in the file (branding, plan-features, the rollout's blanket invalidation) now goes through it instead of callingcacheService.del()bare;PlatformPlanService.updatePlan()does the equivalent inline with its own newly-injectedClsService. Regression-tested by assertingClsService.runWithis actually called with the correct{tenantId}before the cache key is touched — a test that would have passed under the old bare-del()code by simply never noticing the key was wrong, if it only assertedcacheService.del` was called with the right raw key.
| Method | Route | Auth | Notes |
|---|---|---|---|
| GET | /platform/pages/rollout |
PlatformAdminGuard (PAGES_READ) | {enabled: boolean, tenantIds: string[]}, same derivation as Social Media’s but keyed on pages |
| PUT | /platform/pages/rollout |
PlatformAdminGuard (PAGES_WRITE) | {enabled: boolean, tenantIds: string[]} — full replace, not incremental |
Gated by its own dedicated PlatformAdminPermission.PAGES_READ/PAGES_WRITE, mirroring Social Media’s
SOCIAL_MEDIA_APPS_READ/WRITE exactly — originally shipped reusing TENANTS_READ/WRITE instead (reasoning
at the time: “Pages has no other platform-admin surface, so this is fundamentally the same tenant-access-
management action as the generic per-tenant module-override endpoint”), which quietly broke two things: the Admin
Roles screen had no distinct “Pages” checkbox group to grant/revoke (“not showing in the permission list” — a
real report, not a hypothetical), and Pages access could never be granted independently of full Tenants access.
Split via SplitPagesRolloutPermission migration (public schema, platform_admin_roles), which backfills
pages:read/pages:write onto every existing role that already had tenants:read/tenants:write respectively —
not scoped to a specific role name (role names change; see RenamePlatformSuperAdminRole) — so this is a pure
permission-model fix with no access change for any role that already reached Pages through Tenants.
Frontend: a standalone “Pages” page in discuva-platform’s sidebar (/pages, gated by pages:read in both the
sidebar nav item and the page’s own withAuth — previously wired to tenants:read, the frontend half of the same
bug above), containing nothing but the same RolloutPanel pattern Social Media uses (on/off switch + searchable
multi-select of churches). Distinct from the tenant-schema AdminPermission.PAGES_READ/PAGES_WRITE documented
in the Pages module section above, which gates the church-side admin builder instead — same permission names,
two entirely disjoint enums/guards (PlatformAdminPermission/PlatformAdminGuard vs AdminPermission/
AdminGuard), per PlatformAdminPermission’s own file comment. No apps/credentials section, since Pages has no
third-party OAuth concept to register.
departments was Pro-only by accident, corrected via AddDepartmentsToFreePlan. Unlike tithe, this had no
migration or comment ever recording it as a deliberate gate — and it directly contradicted KNOWN_MODULES’s own
required: true flag on departments (src/church-settings/constants/known-modules.constant.ts), which marks it
as a module a church can never disable, i.e. a foundational primitive, not an optional upsell. It’s also the FK
backbone for a wide swath of the app regardless of plan — attendance, worker profiles, finance requests, assets,
games, volunteer opportunities, announcements, and event reminders all reference department_id. A Free-tier
tenant was getting 403 PLAN_UPGRADE_REQUIRED on anything gated by @RequiresModule('departments') as a result.
Fixed by adding departments to free.features (idempotent, WHERE NOT ('departments' = ANY(features))) — pro
and its currency/interval variants (pro-usd, pro-annual, pro-usd-annual) already had it, inherited via
cloning when those variant rows were created.
Internal comps/discounts (Subscription.discountType/discountValue/discountReason/discountExpiresAt): a
platform-admin-only manual comp, set via PATCH /platform/tenants/:id/discount and cleared via
DELETE /platform/tenants/:id/discount (both TENANTS_WRITE, both require an existing Subscription row — apply
PATCH /platform/tenants/:id/plan first if the tenant has none yet). discountType is percentage (1–100,
validated server-side) or fixed_amount (cents); discountExpiresAt is optional — null means permanent until
explicitly removed. Deliberately never touches checkout or a payment provider — same spirit as
sponsoredByTenantId above, and for the same structural reason: Paystack’s recurring charges are driven
by a provider-side Plan object keyed on Plan.billingProviderPriceId, not a per-transaction amount override, so a
discount here can’t change what Paystack actually auto-renews at without creating a distinct provider Plan per
discount tier (out of scope for an internal comp). Its effect is bookkeeping only: PlatformAnalyticsService.mrrByCurrency()
sums each active, non-sponsored subscription’s effective (discounted) price via the shared
computeEffectivePriceCents() helper (src/billing/util/discount.util.ts) rather than the raw Plan.priceCents, so
reported MRR reflects comps; GET /platform/tenants also returns the four discount fields per tenant for display.
MRR is grouped by Plan.currency ([{ currency, mrrCents }]), never blended into one figure — same reasoning as
giving totals below: an NGN-priced and a USD-priced subscription summed together would be meaningless.
Branch Hierarchy (src/branch/)
Lets a parent church invite another church to join as a branch, and see a rollup of its branches’ member
count/attendance/giving — docs/MULTI_TENANT_MIGRATION.md §11’s “local compute, pushed rollups” design, now built.
A branch is a tenant like any other (own schema, own admin, own everything) — the only difference is
tenants.parent_tenant_id is set.
Onboarding: parent admin POST /branch/invites (email) generates a 64-char opaque token (not a JWT — has to be
looked up by value later, not decoded), emails it, and records a pending row in tenant_branch_invites
(public schema — has to live there since the invited church has no tenant of its own yet, the very thing the
invite exists to create). POST /signup accepts an optional branchInviteToken; SignupController resolves and
validates it (pending, unexpired) before enqueueing provisioning, so a bad/expired code fails fast without
creating a tenant row. TenantProvisioningProcessor only marks the invite accepted after provisioning actually
succeeds (see “Async Tenant Provisioning + Onboarding State Machine” above) — a subdomain collision or any other
provisioning failure leaves the invite still usable for a retry rather than burning it.
Plan sponsorship (sponsorPlan, optional on POST /branch/invites): the parent’s stated intent, carried on
the invite row and resolved at signup time — BranchInviteService.resolveInvite() returns a sponsoredPlanId when
sponsorPlan was true and the parent currently has a paid (non-free) subscription; a parent on Free has nothing
to sponsor onto, so this silently falls back to the normal independent Free-tier signup rather than erroring. When
set, TenantProvisioningService.provision() creates the branch’s Subscription directly on that plan with
sponsoredByTenantId pointing at the parent — no checkout, no independent payment. See Billing & Checkout above for
how sponsorship affects cancellation (blocked — it isn’t the branch’s plan to cancel) and MRR (excluded).
Rollup computation (BranchRollupScheduler, daily 03:00 church time, distributed-lock guarded): iterates every
active tenant — not just ones with a parent, since a branch invited later still needs history once linked — and for
each one manually enters that tenant’s CLS/transaction context (BranchRollupService.computeAndUpsertOne, same
mechanism as YoutubeLiveDetectionService/PlatformTenantService.impersonateTenant) to compute:
| Field | Definition |
|---|---|
memberCount |
Count of members with status = ACTIVE |
attendanceRate |
Same PRESENT/LATE/ATTENDED_ONLINE-over-a-window definition AttendanceService.getMyAttendanceSummary already uses per-member (ON_LEAVE excluded from both numerator and denominator), aggregated church-wide over the last 30 days instead. null when there are zero attendance rows in the window, not 0 |
totalGiving |
Sum of tithe_records.amount |
The public tenant_rollups row is upserted outside the tenant-context block (same convention as
YoutubeLiveDetectionService’s own public-row update) using the plain, non-tenant-scoped repository.
Parent-side overview (GET /branch/overview): reads only tenants WHERE parent_tenant_id = :self joined
against tenant_rollups — never reaches into a branch’s schema or shard directly, no cross-tenant read path exists
in this class at all to get wrong (§11.2).
Sharing consent (tenants.share_data_with_parent, tenants.share_giving_with_parent): without this, a
branch’s rollup became visible to its parent the instant an invite was accepted, with no notice or opt-out.
Self-service, settable only by the branch’s own admin via GET/PATCH /branch/sharing-consent — never by the
parent. Gates visibility at getOverview(), not computation — computeAndUpsertOne still computes and stores
every branch’s real numbers regardless (a branch’s own tenant_rollups row is its own data, useful for its own
future views too); consent only controls what’s exposed to a specific parent. shareDataWithParent defaults true
(being a branch structurally implies a reporting relationship — that’s the point of the feature, and the admin who
accepted the invite already knows a parent relationship is being formed) and gates every stat when false (all
fields null, sharingEnabled: false in the response — the parent still sees the branch exists, since they
invited it, just not its numbers). shareGivingWithParent defaults false regardless of the main flag — giving
specifically stays opt-in even when general sharing is on, matching how sensitive individual church finances are
treated everywhere else in this codebase (dedicated TITHE_READ permission, PII-scrubbing conventions).
Un-linking (either side can end the relationship): DELETE /branch/:branchTenantId (parent-initiated — detach
one of this tenant’s own branches, 404 if not actually linked to them) and POST /branch/leave
(branch-initiated — the current tenant leaves its own parent, 400 if it has none). Neither side is permanently
locked into a relationship it no longer wants. Both revoke a sponsored plan on the way out
(revokeSponsorshipIfSponsoredBy) if the departing tenant’s Subscription.sponsoredByTenantId matches the
relationship being severed — continuing free access sponsored by a parent it’s no longer affiliated with wouldn’t
make sense. A subscription sponsored by some other tenant (not the one being unlinked from) is left untouched.
Linking an already-onboarded tenant as a branch (TenantBranchLinkRequest, src/branch/service/branch-link-request.service.ts):
the invite flow above only covers a church that doesn’t have a tenant yet — it can’t be reused for two churches that
are already separate, fully-onboarded tenants, since there’s no signup step left to attach a token to. This is a
two-sided negotiation between two existing tenants instead: a would-be parent’s admin sends a request naming the
target’s subdomain (POST /branch/link-requests), and nothing about either tenant changes until the target’s own
admin, from inside their own tenant context, explicitly accepts or declines it (POST /branch/link-requests/:id/accept//decline) — the parent cannot accept on the target’s behalf, mirroring the same
“write intent first, mutate state only on confirmed action” discipline used everywhere else BYOK/checkout-shaped
flows appear in this codebase. TenantBranchLinkRequest (public schema, tenant_branch_link_requests) is the
sibling control-plane table to tenant_branch_invites, keyed on targetTenantId instead of an email+token pair
since the target already exists to be looked up directly.
Validation at creation time: target subdomain must resolve to a real tenant, a tenant can’t link itself
(target.id === parentTenantId), the target can’t already have a parent, and only one pending request between a
given parent/target pair may exist at a time. On accept, target.parentTenantId is set directly (no provisioning
involved — the target tenant already exists) and, if sponsorPlan was requested and the parent currently has a paid
plan, the target’s existing Subscription row is switched onto the parent’s plan
(planId/status: ACTIVE/cancelAtPeriodEnd: false/sponsoredByTenantId) — same fallback as
BranchInviteService.resolveInvite if the parent is on Free (silently skipped, not an error, since sponsorPlan was
only ever a request). Both sides get a best-effort email notification (target on request creation, parent on
accept/decline) via the same manually-entered tenant-context pattern SubscriptionLapseScheduler.findAdminEmail
already uses (looks up the tenant’s oldest active Admin, ordered by createdAt) — reused here through the shared
runInTenantContext helper rather than duplicated inline, since the recipient’s admin row lives in a schema this
service has no ambient CLS context for.
A parent can have any number of branches — getOverview()/listOutgoing() are both plain unbounded
WHERE parentTenantId = :self queries, and nothing in this feature restricts branch count.
GET /branch/link-requests/outgoing//incoming return BranchLinkRequestView, not the raw entity — the entity
only stores parentTenantId/targetTenantId UUIDs, so both list reads batch-fetch the referenced Tenant rows
(one IN query per list call) and enrich each row with parentTenantName/targetTenantName/targetTenantSubdomain
before returning, the same way BranchInviteService’s invites are already human-readable via the invite’s own
email column.
Un-linking (either side can end the relationship): DELETE /branch/:branchTenantId (parent-initiated — detach
one of this tenant’s own branches, 404 if not actually linked to them) and POST /branch/leave
(branch-initiated — the current tenant leaves its own parent, 400 if it has none). Neither side is permanently
locked into a relationship it no longer wants. Both revoke a sponsored plan on the way out
(revokeSponsorshipIfSponsoredBy) if the departing tenant’s Subscription.sponsoredByTenantId matches the
relationship being severed — continuing free access sponsored by a parent it’s no longer affiliated with wouldn’t
make sense. A subscription sponsored by some other tenant (not the one being unlinked from) is left untouched. This
applies identically regardless of whether the branch was linked via invite or via a link request — unlinkBranch/
leaveParent only look at Tenant.parentTenantId/Subscription.sponsoredByTenantId, not how the link was formed.
Routes (AdminGuard, tenant-scoped):
| Method | Path | Permission | Description |
|---|---|---|---|
| POST | /branch/invites |
BRANCH_WRITE | Body { email, sponsorPlan? } — creates a pending invite, emails the invite code. sponsorPlan: true means the branch is provisioned onto the parent’s current plan at signup, at no independent cost, if the parent is on a paid plan |
| GET | /branch/invites |
BRANCH_READ | This tenant’s own sent invites |
| DELETE | /branch/invites/:id |
BRANCH_WRITE | Revokes a pending invite — 400 if it’s already accepted/revoked |
| GET | /branch/overview |
BRANCH_READ | This tenant’s branches, each joined with its latest rollup, filtered by each branch’s own sharing consent |
| GET | /branch/sharing-consent |
BRANCH_READ | This tenant’s own { shareDataWithParent, shareGivingWithParent, parentTenantId, parentTenantName } — the latter two are null when this tenant isn’t a branch of anything, and are the only way for the frontend to know whether “Leave Parent” is relevant to show at all |
| PATCH | /branch/sharing-consent |
BRANCH_WRITE | Body is a partial of { shareDataWithParent, shareGivingWithParent } — only provided fields are applied; parentTenantId/parentTenantName are read-only and always echoed back |
| DELETE | /branch/:branchTenantId |
BRANCH_WRITE | Parent detaches one of its own branches — 404 if not linked to this tenant |
| POST | /branch/leave |
BRANCH_WRITE | This tenant leaves its own parent — 400 if it has none |
| POST | /branch/link-requests |
BRANCH_WRITE | Body { targetSubdomain, sponsorPlan? } — sends a link request to an already-onboarded tenant. 404 if the subdomain doesn’t resolve, 400 if self-link/target already has a parent/a pending request already exists |
| GET | /branch/link-requests/outgoing |
BRANCH_READ | Link requests this tenant has sent, as a would-be parent |
| DELETE | /branch/link-requests/:id |
BRANCH_WRITE | Parent revokes its own pending request — 400 if no longer pending |
| GET | /branch/link-requests/incoming |
BRANCH_READ | Link requests sent TO this tenant by a would-be parent |
| POST | /branch/link-requests/:id/accept |
BRANCH_WRITE | Target accepts — sets parentTenantId, applies sponsorship if requested and the parent is on a paid plan. 400 if not pending or this tenant already has a parent |
| POST | /branch/link-requests/:id/decline |
BRANCH_WRITE | Target declines — 400 if no longer pending |
Branch hierarchy UI is built in discuva-admin (/branch-hierarchy) — invite-sending, branches overview with
unlink, link-request sending/incoming-review, and a “this church” section showing sharing consent toggles +
leave-parent, only shown when parentTenantId is actually set (see GET /branch/sharing-consent above).
Multi-level hierarchy (branch-of-a-branch) is representable with zero further schema change (parent_tenant_id is
self-referencing) but only flat parent → branch is exercised by anything built so far.
Forms (src/forms/)
Admin-built dynamic forms — not tied to any one use case (events, surveys, sign-ups, and admin-recorded pastoral
records all reuse the same builder). A form has a visibility of MEMBERS, PUBLIC, or ADMIN_ONLY, an optional
link to an Event, and an ordered list of fields (TEXT, NUMBER, EMAIL, PHONE, TEXTAREA, DATE,
DROPDOWN, CHECKBOX — the latter two carry an options array). A PUBLIC form is fillable with no login at all
— the whole point of making it public — MEMBERS forms require an authenticated member/worker token, and
ADMIN_ONLY forms have no member-facing or public-facing fill surface at all — the only way a submission is
ever created against one is POST /forms/:id/submissions (admin-only; see below). ADMIN_ONLY exists for
record-keeping forms an admin fills in on someone else’s behalf rather than the subject self-submitting — e.g.
pastoral records (child naming, dedication, marriage, baptism), where the subject often has no reason or ability
to self-submit (a newborn being named has no account). This is a general-purpose escape hatch, not a fixed set of
pastoral-record types the way a hardcoded “Notes” module would be — an admin defines whatever fields a given
record type needs, and gets the same generic per-field analytics (see below) any other form gets, with zero new
backend code per record type.
Reusable builder templates (builder_templates, tenant migration 1800342000000-CreateBuilderTemplates) —
one tenant-scoped JSONB store backs saved Forms, reusable field groups, and Pages. Each row has kind (FORM,
FIELD_GROUP, or PAGE), a required name, optional description, and opaque data; the URL determines the kind.
Form template routes are GET/POST /forms/templates and DELETE /forms/templates/:templateId; field-group
routes are GET/POST /forms/field-groups and DELETE /forms/field-groups/:templateId. Reads require
FORMS_READ; writes/deletes require FORMS_WRITE, in addition to the Forms module and plan guards. Save bodies
are { name, description?, data: object }. Applying a stored template remains a normal form create/update
operation, so existing form DTO and service validation still applies.
Field order is always explicit, never left to Postgres’s default row order. Form.fields is an eager
@OneToMany relation with no orderBy of its own, so every formRepo.find/findOne call across
FormService/FormSubmissionService passes order: { fields: { order: 'ASC' } } explicitly — an unordered join
doesn’t reliably return rows in FormField.order order (or even the same order twice in a row), which surfaced as
the admin builder’s field list visibly reshuffling itself on every page load/refresh, with no reordering ever
actually requested. getById’s ordering alone covers most call sites (update, cloneForm, getAnalytics,
getSubmissionsCsv, the field-mapping in create/update all route through it); FormSubmissionService has its
own separate formRepo.findOne calls (getForMember, getForPublic, submitAsMember/submitAsPublic/
submitAsAdmin, updateSubmission, getMySubmission) and needed the same fix independently, since none of them
share getById.
Field description: each FormField has its own optional description (text, nullable) — helper text shown
under that field’s label while filling out the form (e.g. “Enter your legal name as it appears on your ID”),
distinct from Form.description, which introduces the form as a whole. Returned on every field DTO (create/update
request, and the public/member-facing PublicFormFieldDto) with no visibility restriction — unlike optionMetadata,
there’s nothing to hide here before submission.
Auto-fill: a field can carry an autoFillKey (FIRST_NAME/LAST_NAME/EMAIL/PHONE_NUMBER) — the
member-facing “get form for filling” endpoint resolves these against the logged-in member’s own profile and
returns them as suggestedValues alongside the field definitions, so the frontend can pre-fill without needing its
own copy of the mapping logic. Never applies to public/anonymous fills — there’s no member to infer from.
Online first-timer intake (Form.createsFirstTimers): a PUBLIC form can be flagged so that every submission
to it also creates a FirstTimer record (FollowUpService.createFirstTimerFromPublicForm) — the online-intake
counterpart to a walk-in visitor being registered by a Follow-Up worker. The same autoFillKey mechanism doubles
as the field-mapping: FormSubmissionService reads the submitted answers back out via whichever fields carry
FIRST_NAME/LAST_NAME/PHONE_NUMBER/EMAIL. Enforced at create/update time (FormService.assertValidFirstTimerConfig):
the form must be PUBLIC, and each of FIRST_NAME/LAST_NAME/PHONE_NUMBER must be mapped to a field that is
itself required: true — not just present — since CreateFirstTimerDto’s own class-validator decorators never
run against a service-constructed object, so this is the only real guard against an empty name/phone reaching
FirstTimer. The resulting FirstTimer.source is always forced to ONLINE regardless of whatever the form’s own
fields submit, and it’s created with no actor (memberCreatorId/adminCreatorId both unset) — same round-robin
Follow-Up-worker assignment and task-creation path as every other first-timer registration route. This side effect
is fault-tolerant: a failure (e.g. no active Follow-Up worker configured yet) is logged but never blocks the
submission itself from saving — an anonymous visitor filling this in from a QR code never sees an error. The
intended distribution path is a QR code linking to the form’s public URL, printed/displayed for walk-up scanning
or shown during a livestream (generated client-side in discuva-admin — no backend endpoint involved).
Submissions are keyed by field id inside a jsonb blob (FormSubmission.answers: Record<fieldId, value>),
not a normalized per-answer table — editing or removing a field later never requires migrating past submissions; a
removed field just leaves a harmless orphaned key behind in old submissions’ JSON, still readable/exportable.
Admin notification on submission (Form.notifyOnSubmission, default false): a per-form opt-in — when on,
FormSubmissionService.notifyAdmins emails every active Admin whose adminRole.permissions includes
FORMS_WRITE (form-submission-new template) after a successful save, fire-and-forget (a failure here is logged,
never surfaces to the submitter). Gated by both this per-form flag and the tenant-wide
EmailCategory.FORM_SUBMISSION toggle (§10’s EMAIL_FORM_SUBMISSION_ENABLED platform kill switch, and the
per-church override under Notification Settings → Automated Emails) — the same defense-in-depth every other
EmailCategory already has. Only fires from submitAsMember/submitAsPublic; submitAsAdmin never notifies,
since an admin recording something on someone’s behalf doesn’t need to be told about it.
Per-option link + description, an always-shown general action, and dynamic post-submission “next steps”: a
DROPDOWN/CHECKBOX field’s options can each carry optionMetadata: Record<option, { url?, description? }> —
e.g. a “Department” dropdown where each department option has its own WhatsApp group link and one-line
description. A form separately designates one DROPDOWN field (Form.nextStepsField, nextStepsFieldId on
create/update — CHECKBOX is rejected here, since its multi-select answers don’t map to “one selected option’s
metadata”) and every submit endpoint now returns { submissionId, nextSteps: { message, generalAction, selectedOption } } instead of the raw FormSubmission row: message is the form’s own postSubmitMessage (or
null); generalAction — { label, url } | null — is the form’s own generalActionUrl/generalActionLabel
(FormService.assertValidGeneralAction: both must be set together, or both left empty, and the URL must parse),
an always-shown second call-to-action independent of what was answered (e.g. “Join the Main Volunteer Group”,
same for every submitter); selectedOption — { value, url, description } — is resolved from whichever option
the visitor actually picked, or null if no nextStepsField is configured. These two links are independent and
both optional — a form can have either, both, or neither. Only the chosen option’s metadata is ever returned
— the public GET /forms/public/:id (and the member-facing equivalents) strip optionMetadata from every field
entirely, so no other department’s link is ever visible before that option is submitted, and generalActionUrl/
generalActionLabel are likewise omitted from that response — both call-to-actions only ever appear on the
post-submission response, never before. Validated at create/update (FormService.assertValidOptionMetadata):
every optionMetadata key must be one of that field’s own options, and any url must parse as a real URL.
Ranked conditional overrides for the post-submission message/action (Form.postSubmitOutcomes, nullable jsonb
array): each entry is { conditions: [{ fieldId, operator, value }, ...], message, hideMessage, actionUrl, actionLabel, hideAction } — conditions reuses FormField.visibilityRule’s exact same shape/operator set
(equals/notEquals/includes), just as an array instead of a single condition, ALL of which must match (AND)
for that outcome to apply. FormSubmissionService.resolvePostSubmitContent evaluates postSubmitOutcomes in
array order at submit time against the just-submitted answers — the first outcome whose conditions all match
wins. Resolution is per-field, not a paired all-or-nothing swap, and the message and the actionUrl/
actionLabel pair are each an independent three-way choice on the winning outcome:
hideMessage/hideActiontrue→ show none of that piece for this response, even though the form has a staticpostSubmitMessage/generalActionUrl+generalActionLabeldefault.normalizePostSubmitOutcomesforcesmessage/actionUrl/actionLabeltonullwhenever their own hide flag istrue, so there’s never an ambiguous “hidden but a value is also set” state in storage —resolvePostSubmitContentnever even reads those fields when the hide flag is set.- hide flag
falseand the value isnull→ inherit the form’s own static default, independently per piece — so one rule can override just the button while leaving the default message alone, or vice versa. - hide flag
falseand the value is set → use that custom value.
No match (or no outcomes configured) falls back to the static fields unchanged, so an older form with none
behaves exactly as before, and a form created before this three-way choice existed (both hide flags absent) reads
as false for both — identical to its old inherit-on-null-only behaviour. Condition evaluation is shared with
isFieldVisible via a new evaluateCondition helper rather than duplicated. Deliberately independent of
nextStepsField/optionMetadata’s per-selected-option selectedOption link (see above), which still resolves
and is returned alongside whatever postSubmitOutcomes produces — the two mechanisms answer different questions
(“what does the option they picked lead to” vs. “does this whole submission, considered together, warrant a
different message/action”) and compose rather than conflict.
Validated at create/update (FormService.assertValidPostSubmitOutcomes): every condition’s fieldId must reference
a field already present among the incoming fields — the same real-world constraint visibilityRule has, since no
field has an id yet on create(), outcomes are edit-only in practice — and each outcome’s own actionUrl/
actionLabel reuse assertValidGeneralAction’s pairing check (both set or both empty), skipped entirely when
hideAction is true (the pair is forced to null regardless, so there’s nothing meaningful to pair-check, and
a stray value the client didn’t clear shouldn’t block the save). On update, validated against dto.fields when
the request touches fields (so a field this same request is about to delete can’t be referenced) or the form’s
current fields otherwise. CloneFormDto has no override for postSubmitOutcomes — like visibilityRule, every
condition’s fieldId points at a source field id that won’t exist post-clone, so there’s no sensible value an admin
could supply before the clone’s own fields exist. Instead cloneForm always inherits and re-matches it from the
source by label (FormService.remapClonedPostSubmitOutcomes, the same by-label technique remapClonedVisibilityRules
uses, carrying hideMessage/hideAction through unchanged via its object spread) — if even one condition inside an
outcome can’t be re-matched, the whole outcome is dropped rather than left partially broken (an outcome needs
every one of its conditions to mean anything), mirroring remapClonedVisibilityRules’s own “drop rather than leave
dangling” stance.
Duplicate-submission prevention: a form can designate one field (Form.dedupField, dedupFieldId on
create/update — DROPDOWN/CHECKBOX excluded as poor dedup keys) whose submitted value must be unique per form. On
submit, that field’s value is normalized (phone-normalized if it’s a PHONE field, else trimmed+lowercased) into
FormSubmission.dedupValueNormalized, enforced by a DB-level partial unique index
((form_id, dedup_value_normalized) WHERE dedup_value_normalized IS NOT NULL) — not just an application check, so
two near-simultaneous duplicate submissions can’t both slip through a race. A unique-constraint violation
(err.code === '23505', same pattern as SmallGroupService) is translated into a friendly BadRequestException
carrying a structured code: 'DUPLICATE_SUBMISSION' alongside its message (same “extra keys spread into the
response body” convention as PlanGuard’s PLAN_UPGRADE_REQUIRED — see http-exception.filter.ts), so the fill
page can render a distinct “you’re already registered” screen instead of routing it through a generic error
banner, rather than just matching a display string.
Phone normalization (src/utility/decorators/normalize-phone.decorator.ts, normalizePhoneNumber, backed by
libphonenumber-js — this platform is multi-tenant/multi-country, not Nigeria-only, so hand-rolled Nigeria-shaped
regex would incorrectly reject or mangle any other country’s number): every PHONE-type field’s submitted value is
parsed and normalized to E.164 before it’s persisted. A number already carrying its own country code (a leading
+, or a bare international dialing code) parses correctly for any country regardless of the tenant’s own
default — e.g. a Nigerian church’s diaspora member submitting a UK number still normalizes correctly. A
LOCAL-format number with no country code (e.g. a bare 0801234567) is interpreted against defaultPhoneRegion,
derived once per FormSubmissionService instance from the CURRENCY_LOCALE env var’s region subtag (en-NG →
NG) — the same per-deployment default already used for currency/date formatting elsewhere (TitheService,
PdfService, EventReminderService), not a Nigeria-specific hardcode. Anything that doesn’t parse as valid for
its (explicit or assumed) country returns null (a required PHONE field that fails to normalize is rejected
with a 400 — never silently mangled or dropped). The normalized value is what’s actually stored in answers, so
exports, analytics, and dedup all ever see one canonical shape for the same real number. The same rule applies to
every phone field on the API — see Phone Number Storage (E.164) below.
Phone Number Storage (E.164): every phone number written through the API is stored in E.164 (+ + country code
- national number, e.g.
+2348012345678). Clients may send local format (08012345678), a bare country code (2348012345678) or spaced/dashed input — the DTO decorators@NormalizePhone() @IsNormalizedPhone()convert it, using theCURRENCY_LOCALEregion (defaultNG) for numbers without a country code. Anything that doesn’t parse as a valid number for its country is rejected with400and a region-aware message built fromCURRENCY_LOCALE, e.g.Please enter a valid phone number (e.g. 0802 123 4567), or include the country code (e.g. +44…) for numbers outside Nigeria.(invalidPhoneMessage(); clients should show it as-is). A blank or whitespace-only phone is treated as not provided — accepted on optional fields, rejected with the same message on required ones (first-timer phone). Exception: onPATCH /members/meandPATCH /members/:id,phoneNumber: ""ornullclears the stored number (@NormalizePhone({ clearable: true })); omitting the field leaves it unchanged. Length/prefix rules come fromlibphonenumber-jsper country — never hardcode digit counts.
| DTO | Field | Endpoint(s) |
|---|---|---|
SignupDto |
phoneNumber |
POST /auth/signup, POST /members (admin create) |
UpdateMemberDto |
phoneNumber |
PATCH /members/:id |
UpdateMyProfileDto |
phoneNumber |
PATCH /members/me |
CreateGuardianDto |
phoneNumber |
POST /children-church/children/:id/guardians |
EnrollGuestDto / BulkGuestEntryDto |
phone |
POST /classes/enroll/guest, POST /classes/enroll/guests/bulk |
CreateFirstTimerDto / UpdateFirstTimerDto |
phone |
POST /follow-up/public/first-timer, POST/PATCH /follow-up/first-timers[/:id], POST/PATCH /admin/follow-up/first-timers[/:id] |
CheckInFirstTimerDto |
phone |
POST /sunday-school/sessions/:id/checkin-first-timer |
CreateConvertDto |
phone |
POST /evangelism/converts |
Also normalized in-service: member bulk import, group phone-only entries, PHONE form fields, and SMS recipients at
send time (recipients are deduped after normalization, so 0801… and +234801… send once). Not normalized: ExternalPayee.contactPhone (finance contact, never messaged).
Backfilling existing data: npm run phones:normalize:all-tenants (prod: phones:normalize:all-tenants:prod) walks
every active tenant and rewrites members.phone_number, group_members.phone_number, child_guardians.phone_number,
first_timers.phone, converts.phone and guests.phone to E.164. Dry run by default — prints per-tenant counts and
every row needing manual review; pass -- --apply to write. Unparseable numbers are left untouched and reported;
a group_members row whose normalized number already exists in the same group is skipped and reported (never merged
or deleted). Idempotent — safe to re-run.
Form branding — cover image and logo: Form.coverImageUrl/coverImagePublicId and Form.logoUrl/
logoPublicId (mirrors Tenant.logoUrl/logoPublicId’s shape) are set via dedicated upload endpoints (see table
below), Cloudinary-backed (CloudinaryService.uploadBuffer, folders form-covers/form-logos) with the same
“delete the previous asset only after the new one is safely saved” ordering used by TenantInfoController’s own
logo upload. Both are optional and independent of the tenant’s own logo — the public fill page renders a form’s
own logo in place of the generic tenant logo when set, and falls back to the tenant logo otherwise; a cover image
renders as a banner above the form title when set, with no fallback (most forms have none).
Audience restriction via Contact List: a MEMBERS-visibility form can be restricted to members of one Group
(“Contact List” in the admin UI) — Form.audienceGroup/audienceGroupId. audienceGroupId is rejected outright
on a PUBLIC/ADMIN_ONLY form (FormService.assertValidAudienceGroup) — there’s no member identity to check
against there. When set, GET /forms/member filters it out of the list for anyone not in that group (an EXISTS
subquery against group_members, mirroring AnnouncementService.getForMember’s own group-membership check), and
GET /forms/member/:id / POST /forms/member/:id/submit 404 for an outside member exactly as if the form didn’t
exist — no distinct “you’re not allowed” response that would leak the form’s existence. audienceGroupId: null
(explicit) clears the restriction; omitting it on a PATCH leaves the current value untouched. This deliberately
reuses the existing Group/Contact-List feature rather than introducing a parallel “specific list” concept — e.g. a
church restricting a form to department heads first builds a “HODs” Contact List, then points the form at it.
Field diff-sync on update: PATCH /forms/:id’s fields array is diffed against the form’s existing fields —
an incoming field with an id updates that row in place (keeping the id stable so existing submissions’ answer
keys stay meaningful), one without an id is a new field, and an existing row missing from the incoming array is
deleted. Omitting fields entirely from the PATCH body leaves them untouched.
Quiz + Voting (Form.purpose: STANDARD | QUIZ | VOTE, default STANDARD): two purpose-built configurations
of the same engine — every existing form is STANDARD and behaves byte-for-byte as before this existed.
VOTEmust beMEMBERSvisibility (FormService.assertValidPurposeConfigrejectsPUBLIC/ADMIN_ONLYoutright — there’s no secret-ballot/anonymity design here, a vote’s submissions carry the samememberidentity every otherMEMBERSsubmission does).Form.oneResponsePerMember(boolean) enforces identity-based one-submission — distinct from the pre-existingdedupFieldmechanism, which dedupes on a submitted value (e.g. a phone number), not on who submitted.submitAsMemberchecks this before saving and throws the sameDUPLICATE_SUBMISSIONshapededupField’s23505path already throws ("You've already voted."), so the frontend’s existing duplicate-submission handling needs no changes. AVOTEfield’soptionsmay not contain a case-insensitive-trimmed duplicate ("Vote choices must be unique — ... is listed twice.") — a data-quality guard so two candidates can’t accidentally collide. The tally itself needs no new code at all:GET /forms/:id/analytics’s existingchoices: [{option, count, percentage}]breakdown (anyDROPDOWN/CHECKBOXfield) already is the vote count.choicesis sortedcount DESC(stable — a tie keeps the options’ original declared order), so the leading choice is always index 0 rather than something an admin has to scan every percentage to find; discuva-admin’s Analytics panel renders a “Leading” badge on it forVOTEforms specifically (“Tied” instead, when the top two choices are exactly even).QUIZadds auto-grading.FormField.correctOptions(nullable text array,DROPDOWN/CHECKBOXonly, validated against that field’s ownoptions) marks which value(s) are correct;FormField.points(nullable smallint, meaningful only alongsidecorrectOptions;nullmeans1, the flat per-question value scoring always used before this column existed) is how many marks that question is worth.FormSubmissionService. scoreQuizSubmissioncompares each scorable field’s submitted answer at submit time and writesFormSubmission. score/maxScore(both nullable, bothnullfor non-QUIZsubmissions) —DROPDOWNis correct when the single submitted value is incorrectOptions,CHECKBOXonly when the submitted set exactly equalscorrectOptions(no partial credit); a correct answer earns that field’spoints(default1), andmaxScoreis the sum of every scorable field’spoints, not a flat count of scorable fields. A field with nocorrectOptionsset simply doesn’t count toward the total — aTEXT/TEXTAREAshort-answer/essay question (allowed on aQUIZ— see below — but never auto-gradable) is never scored, reviewed manually by a teacher/ admin instead.Form.revealScoreImmediately(defaulttrue) controls whether the score comes back on the submit response right away, or is withheld ({scorePendingUntil: closesAt}instead of{score, maxScore}) until the window closes — guards against an early finisher’s result (and by extension which questions they got right) leaking to classmates sitting the same quiz during a shared open window. A withheld score is still computed and stored at submit time;GET /forms/member/:id/submissionreveals it oncenow > closesAt. No per-person score list on the member side — a submitter only ever sees their own via that route orGET /forms/member/history(below) — but the admin does get one:GET /forms/:id/submissions?sortBy=score(FormService.getSubmissions) sortsNULLS LASTbyscore DESC(a query-builderorderBy, not TypeORM’s plainorderoption, which defaultsDESCtoNULLS FIRSTin Postgres — that would rank an unscored submission, e.g. one from before an admin addedcorrectOptionsto a question, above every real score) withcreatedAt DESCas the tiebreak. discuva-admin’s Submissions panel surfaces this as a “Most Recent”/“Highest Score” toggle forQUIZforms, rendering a Rank/Participant/Score/Submitted-At table (the same compact-table treatmentVOTE’s Voter/Choice/Voted-At table already gets, instead of the generic multi-field card layout).- Member-facing gaps found after shipping (both fixed): (1) discuva-member’s
submit()/updateSubmission()(hooks/use-forms.ts) only ever extractedres.data.data.nextSteps, silently droppingscore/maxScore/scorePendingUntil— those three are top-level siblings ofnextStepsinFormSubmitResponseDto, not nested inside it, so aQUIZsubmitter never actually saw their score even withrevealScoreImmediately: trueand a correctly-computed score server-side. Fixed by aflattenSubmitResulthelper that merges the two shapes into the one flat object the fill page (app/forms/[id]/page.tsx) already readsresult.score/result.messageoff of interchangeably. (2)GET /forms/member/:id(getForMember) gainsattemptCount— how many times this member has already submitted this form (submissionRepo.count, scoped to(form, member)) — so the fill page can show “You’ve attempted this N times” alongsideForm.oneResponsePerMember(already present on the returnedformobject, just not previously surfaced in the member UI) instead of a member only discovering whether retakes are allowed by trying to submit again. GET /forms/member/history(FormSubmissionService.getMyHistory) — “see scores/votes for previous events.” EveryQUIZ/VOTEsubmission the calling member has ever made, most recent first, regardless of whether the form itself is still active (a member can still look back at last year’s election vote or an old sermon quiz score after the form is deactivated/archived). Optional?purpose=QUIZ|VOTEnarrows it to one. Paginated (PaginationResponseDto), each row{formId, formTitle, formPurpose, submittedAt, score, maxScore, choice}—score/maxScorepopulated only forQUIZrows,choice(the submitted answer, joined if an array) only forVOTErows, since aVOTEform is always a single choice question (assertValidPurposeConfigenforcesvisibility: MEMBERSand rejects anything butDROPDOWN/CHECKBOXfields forVOTE, so “the first answer” is unambiguous). Deliberately excludesSTANDARDsubmissions — an arbitrary multi-field form’s answers aren’t a score/choice worth surfacing in a history list the same way.- A
VOTE/QUIZfield’sfieldTypeis restricted, not the full 9-type pickerSTANDARDforms get (FormService.assertValidPurposeConfig, checked againstCHOICE_FIELD_TYPES/QUIZ_FIELD_TYPESat create/update — the exact allowlist discuva-admin’sfield-editor.tsxalso filters its type<select>to, so an admin never sees a choice the server would reject). AVOTEfield must beDROPDOWN/CHECKBOX— a voter’s identity is already the logged-in member, so there’s never a reason to collect a name/email/phone/etc. alongside a ballot. AQUIZfield may additionally beTEXT/TEXTAREA(for the manually-reviewed short-answer case above); every other type (NUMBER,EMAIL,PHONE,DATE,FILE) has no real place on either and is rejected outright —"<label>": a vote field must be Dropdown or Checkbox/"<label>": a quiz field must be Dropdown, Checkbox, Text, or Long Text. discuva-admin’s Purpose switcher coerces any existing field outside the new purpose’s allowlist toDROPDOWNthe moment an admin picksVOTE/QUIZ(coerceFieldsForPurpose), rather than leaving it to block Save with no explanation. - A
QUIZsubmission’sForm.editableAfterSubmitis always forced tofalse, regardless of what a create/ update/clone request sends — enforced at all three write paths (FormService.create/update/cloneForm) plus a defense-in-depth rejection directly inFormSubmissionService.updateSubmission("Quiz answers cannot be edited after submitting.") so the invariant holds even if it were ever set incorrectly upstream. Letting an answer be revised after the score was already computed — and very possibly shown, whenrevealScoreImmediatelyis on — would turn “test” into “look up the answer key and fix it.” The admin edit form reflects this: forpurpose === QUIZthe usual “Let members edit their response” checkbox is replaced with an explanatory locked notice, and an “Allow retakes” checkbox (wired toForm. oneResponsePerMember, inverted — checked means retakes allowed) is the correct way to let a member attempt aQUIZagain, via theFormAttempt/retake flow below, never a silent edit of the old submission.
Time-boxing (Form.opensAt/closesAt, both nullable timestamptz — an exact date-and-time instant, not
date-only like ChurchCalendar.startDate/endDate): available on any purpose but only surfaced in the admin
UI for QUIZ/VOTE. Both null (the default) means always open, unchanged for every existing form.
FormSubmissionService.assertWithinWindow gates submitAsMember/submitAsPublic/updateSubmission (editing an
already-cast vote after the window closes is blocked too, not just a fresh submission) — independent of the
pre-existing isActive flag; both gates must pass. Deliberately not applied to submitAsAdmin — an admin
backfilling/correcting a record is an intentional override, the same posture that method’s unrestricted-visibility
access and skipped stripHiddenAnswers pass already have. An admin-entered wall-clock value (e.g. “5:00 PM”) is
interpreted in the church’s own configured timezone via DateService.toChurchInstant (fromZonedTime against
the TIMEZONE env var, the same trick DateService.startOfDay/endOfDay already use), not the browsing admin’s
or the server’s — so “closes at 5 PM” means 5 PM church time regardless of who sets it or from where.
A QUIZ with Form.timeLimitMinutes set (smallint, nullable) additionally gets a per-attempt countdown —
scoped to MEMBERS visibility only (an anonymous PUBLIC submission has no identity for a personal clock to
track). New entity FormAttempt (form_attempts: form, member, startedAt, expiresAt, submission
nullable — set once consumed) has no hard unique constraint on (form, member); a member can accumulate more
than one row over time, and FormAttemptService decides what to do with them:
POST /forms/member/:id/start(startOrGetAttemptById→startOrGetAttempt) returns the existing attempt unchanged if one is already in progress (unconsumed, unexpired) — idempotent, so a page refresh mid-quiz doesn’t reset the clock.opensAt/closesAtgate starting a new attempt, not finishing one already running.- If the most recent attempt is already consumed or expired unconsumed, a fresh attempt is only allowed when
Form.oneResponsePerMemberisfalse(retakes allowed) — otherwise"You've already completed this quiz." expiresAt = min(startedAt + timeLimitMinutes, closesAt ?? Infinity), computed and stored once at start — a later admin edit totimeLimitMinutes/closesAtnever retroactively changes an attempt already in progress (changing the rules mid-exam for people already sitting it would be a bug, not a feature).submitAsMemberfor such a form callsFormAttemptService.assertValidForSubmitbefore saving — requires an unconsumed attempt within its ownexpiresAt, else"Start the quiz before submitting."/"Time's up — this attempt has expired."— and consumes it (consumeAttempt, setsattempt.submission) once the submission actually saves.GET /forms/member/:id’s response gainswindowState('OPEN' | 'NOT_OPEN_YET' | 'CLOSED', computed fromopensAt/closesAt) andattempt({startedAt, expiresAt} | null, the current in-progress attempt if any) — lets the fill page show the right state (not-yet-open, closed, or “resume your in-progress attempt”) instead of only surfacing a window/attempt error at submit time.
Cloning (POST /forms/:id/clone, FormService.cloneForm): modeled on PrayerConfigService.cloneProgram —
title is the only required field on CloneFormDto; every other scalar follows an “omitted = inherited from the
source, explicit null = cleared, value = override” convention (same as UpdateFormDto’s nullable fields). The
clone always starts isActive: false (an admin reviews it before it goes live) and with no cover/logo — the two
Forms would otherwise share a Cloudinary publicId, so removing the clone’s cover would delete the original’s.
fields themselves are not part of the DTO: they’re always deep-copied from the source verbatim, each getting
a fresh id — a clone’s fields are edited afterwards via the normal PATCH, not at clone time. dedupField/
nextStepsField are re-matched by label against the freshly-cloned fields (the only stable key once ids are
gone), the same .update()-not-.save() two-phase approach applyCrossFieldRefs already uses. FormSubmissions
are never cloned. purpose/oneResponsePerMember/timeLimitMinutes/revealScoreImmediately and each field’s own
correctOptions are all carried over verbatim (the same “worth preserving structural behaviour” reasoning as
fields itself), but opensAt/closesAt always reset to null — a specific schedule never makes sense to copy
onto a brand-new, unreviewed clone.
Answer validation happens server-side against the form’s actual field definitions, not via a fixed DTO shape
(SubmitFormDto.answers is just Record<string, unknown> — the schema is per-form, not knowable at compile time):
required fields must be present and non-empty, DROPDOWN/CHECKBOX values must be one of the field’s
configured options, and EMAIL/NUMBER/DATE fields are format-checked (isValidEmail/isValidNumber/
isValidDateString, src/utility/decorators/form-answer-validators.ts — thin wrappers around class-validator’s
isEmail/isNumberString/isDateString). Format checks are validate-only: unlike PHONE’s
normalizePhoneNumber, the stored answer is never rewritten — a NUMBER answer stays whatever numeric string was
submitted, so CSV export/analytics’ existing string-tolerant handling of answers is unaffected. An empty optional
field skips both the options and format checks, same as it always has.
Bound constraints (minValue/maxValue, minLength/maxLength, minSelections/maxSelections, all nullable
on FormField): each pair only applies to its matching fieldType — minValue/maxValue to NUMBER,
minLength/maxLength to TEXT/TEXTAREA, minSelections/maxSelections to CHECKBOX — enforced at
create/update time by FormService.assertValidFieldConstraints (rejects a bound set on the wrong fieldType
outright, and max < min when both are set on the same field) — this check treats an explicit null the same as
omitted (== null, not === undefined): the admin field editor sends an explicit null for every bound that
doesn’t apply to whatever fieldType a field is switched to (e.g. picking PHONE clears minValue/maxValue), and
since a form’s fields array is always saved as a full replace rather than a per-property patch, “omitted” and
“explicitly cleared” mean the same thing here — unlike the top-level Form scalars in update(), which do
distinguish the two. FormSubmissionService.validateAnswers re-checks
the bound at submit time (validateFieldBounds, after validateFieldFormat so a malformed NUMBER answer is
already rejected before its value bound is even checked) — a null bound means unbounded on that side, and an
empty optional field skips bound checks the same way it skips every other check. PublicFormFieldDto carries all
six through unchanged for the fill UI’s own native-input hinting (min/max/minLength/maxLength HTML
attributes; minSelections/maxSelections has no native HTML equivalent, shown as helper text instead) — that
client-side hinting is convenience only, not the real enforcement.
Custom pattern validation (FormField.validationRegex/validationMessage, both nullable strings): TEXT/
TEXTAREA only — a submitted answer must match new RegExp(validationRegex).test(value)
(FormSubmissionService.validateFieldPattern, run after validateFieldFormat/validateFieldBounds in
validateAnswers), rejected with validationMessage if set, else a generic "<label>" is not in the required format. FormService.assertValidFieldPattern (called from both create/update, alongside
assertValidFieldConstraints) rejects a pattern set on the wrong fieldType outright, and rejects a
syntactically invalid regex (new RegExp() throwing) as a 400 at save time rather than only surfacing the
first time someone submits against it. Both fields are capped at 200 characters at the DTO level
(@MaxLength(200)) — defense-in-depth against a pathological catastrophic-backtracking pattern; the value is
admin-authored (AdminGuard + FORMS_WRITE), not visitor input, but the cap costs nothing and narrows the blast
radius regardless. PublicFormFieldDto carries both through for the fill UI’s own hinting: the native HTML
pattern/title attributes only apply to <input type="text"> (not <textarea>, not type="email"/"tel"/
"number"/"date"), so TEXT gets the real browser-native attributes and TEXTAREA falls back to a plain
helper-text hint — both convenience only, not the real enforcement. The admin field builder shows a live
client-side regex-syntax check (red border + inline warning) purely as authoring feedback; the server re-checks
syntax independently at save time regardless.
Multi-page forms (FormField.pageIndex, smallint, default 0): a plain grouping key, not a first-class
FormPage entity — every field defaults to page 0, so an older form (or one that never opts into pagination)
renders and submits exactly as before. Grouping/rendering is entirely a member-frontend concern
(components/forms/paginated-form-fill-fields.tsx’s PaginatedFormFillFields, wrapping FormFillFields per
page rather than replacing it — used by both the member and public fill pages): page count is derived from
Math.max(...fields.map(f => f.pageIndex)), Back/Next navigate between pages, and a Next click does a
client-side required-field check on the current page only (mirrors validateAnswers’ own isEmpty check, but
is convenience-only — a page not yet visited is unmounted, so it never gets native HTML5 validation at all).
There is deliberately no backend pagination logic and no draft/partial-save: a submission is still one atomic
final POST of every page’s answers together, same as a single-page form always was — an abandoned mid-form
visitor simply never submits. PublicFormFieldDto carries pageIndex through unchanged for the fill UI’s own
grouping. The admin field builder (field-editor.tsx) gets a numeric “Page” input per field (1-based in the UI,
0-based in pageIndex) and visually groups the field list by page once a form actually uses more than one —
each page section is independently collapsible; a new field added via “Add Field” continues on whichever page
was last in use rather than always resetting to page 1.
Conditional/branching logic (FormField.visibilityRule, nullable jsonb — { fieldId, operator, value },
operator one of equals/notEquals/includes): one rule concept, lives on FormField only — there’s no
separate page-level rule; a page effectively disappears when every one of its fields is hidden by its own rule,
evaluated by the same function on both the admin builder and the fill renderer. Deliberately a plain jsonb
column, not a @ManyToOne relation — unlike dedupField/nextStepsField, this never enters TypeORM’s
topological sorter at all (a jsonb column carries no relation semantics for it to see), so it sidesteps the
Form.fields cyclic-dependency class of bug entirely rather than needing that pair’s .update()-not-.save()
workaround — set directly in the same fieldRepo.create()/fieldRepo.save() call as every other field property.
fieldId must reference another field on the same form that already has an id — the same constraint
dedupFieldId/nextStepsFieldId have in practice, since the admin picker only ever offers existing fields
(FormService.assertValidVisibilityRules, called from both create/update; rejects a self-reference too). A
direct consequence: a rule can never be set at create time (no field has an id yet at that point) — same
real-world constraint as dedup/next-steps, which are also edit-only in the admin UI. cloneForm re-matches each
rule’s fieldId to the freshly-cloned target by label (remapClonedVisibilityRules, the same by-label technique
applyCrossFieldRefs uses) — a rule whose target somehow didn’t survive the clone is silently dropped rather
than left dangling.
When the trigger field has a fixed option set (DROPDOWN/CHECKBOX), assertValidVisibilityRules also rejects a
value that isn’t one of that field’s own options — a value that can never match anything a visitor actually
submits would otherwise fail silently (the rule just never fires, with no error anywhere to explain why). The
admin field builder avoids this case by construction: once a trigger with options is selected, the condition’s
value input becomes a <select> of that field’s own options instead of free text, so a typo or case mismatch
can’t be typed in the first place — this validation is the server-side backstop for anyone calling the API
directly.
FormSubmissionService.isFieldVisible evaluates a rule against the submitted (or, client-side, in-progress)
answers: equals/notEquals compare a scalar answer as a string (an array/object answer — CHECKBOX/FILE — is
never treated as “equal” to a typed value, rather than falling back to a meaningless Object-stringified
comparison); includes array-contains for a CHECKBOX target or substring-matches for free text. validateAnswers
calls this before a field’s required check and skips every other check too when hidden — a
conditionally-hidden field never blocks submission and its leftover value (if any) is never validated, regardless
of what the client happened to render. No cycle detection anywhere: each field’s visibility is evaluated
independently against the answers, never against another field’s own computed visibility, so a rule chain (or an
accidental cycle) can’t recurse.
A hidden field’s leftover value is stripped before it’s ever persisted, not just exempted from validation
(FormSubmissionService.stripHiddenAnswers, run after validateAnswers succeeds so validation itself keeps seeing
every raw submitted value — only what actually gets saved is affected). The fill UIs only stop rendering a field
once a prior answer hides it; they never clear that field’s own local edit state, so a value typed before the
field went hidden is still present in the submit payload. Left in the saved record, that stale answer would
silently pollute CSV export and FormService.getAnalytics with a response the submitter never actually confirmed
seeing. Applied on submitAsMember/submitAsPublic/updateSubmission — deliberately not on submitAsAdmin,
since the admin’s own record-entry UI shows every field unconditionally regardless of visibilityRule, so any
answer reaching that path was something an admin actually saw and typed, never a stale leftover. Every field’s
hidden/visible determination is evaluated against the same fixed pre-strip snapshot regardless of which order
fields happen to be stripped in, so one field being hidden can never change another field’s own visibility result.
A FILE field’s now-unclaimed upload (hidden, so its {url, publicId} answer is stripped rather than saved) is
picked up by the normal 48h orphan sweep like any other abandoned upload, rather than being treated as claimed.
On the member/public fill side, form-fill-fields.tsx exports the identical evaluation logic
(isFieldVisible, duplicated rather than shared across the repo boundary) and filters fields live on every
render; PaginatedFormFillFields uses the same function to skip a page with zero currently-visible fields during
Back/Next navigation and on initial mount — but doesn’t re-scan the current page reactively while the visitor
is sitting on it (changing an earlier answer that would hide the current page doesn’t yank them off it
mid-view), and structural page count (progress bar, single-vs-multi-page chrome) is fixed at mount rather than
recomputed as pages become runtime-hidden. The admin field builder’s “Show this field only if…” control
(field-editor.tsx) lives in each field’s own state, reusing the exact fields.filter((f) => f.id && ...)
pattern the dedup/next-steps pickers already use; deleting a field client-side also proactively clears any other
field’s visibilityRule pointing at it, rather than letting the save round-trip fail with an “unknown field”
error.
Submitter response editing (Form.editableAfterSubmit, boolean, default true): member-only — a public/
anonymous submission carries no member identity to look one back up by, and there’s no login for an anonymous
visitor to come back through anyway, so the edit surface is unreachable for submitAsPublic regardless of this
flag. GET /forms/member/:id/submission (FormSubmissionService.getMySubmission) powers the member fill page’s
“you already submitted — edit it?” flow, reached from the DUPLICATE_SUBMISSION error submit already throws: it
returns the caller’s most recent submission for that form ({ submissionId, answers, editable }, most recent wins
when more than one exists — only possible when the form has no dedupField) via a composite (form_id, member_id)
index (IDX_form_submissions_form_id_member_id, since the base migration only ever indexed those columns
separately) with editable mirroring
Form.editableAfterSubmit, so the frontend can show a read-only “no longer editable” message instead of an edit
link without a second round trip. PATCH /forms/member/submissions/:submissionId (updateSubmission) re-runs the
exact same normalizeAnswers/validateAnswers pipeline a fresh submit does — 4a’s bounds and 4c’s
visibility-aware required-skipping both apply — but never calls notifyAdmins (an edit isn’t a new-submission
event), and only recomputes/rewrites dedupValueNormalized when the dedup field’s value actually changed. A 23505
conflict on save (the edited value collides with a different submission’s dedup value) is reported the same
DUPLICATE_SUBMISSION way a fresh submit’s own conflict is. Ownership is resolved entirely from the submission
record itself (submission.member.id === callerId) rather than trusting anything from the URL beyond the
submission id, matching how every other check in this module resolves from the form/member tokens. Both endpoints
additionally 404 on an ADMIN_ONLY form even when the caller happens to be the memberId attached to one of its
submissions (e.g. a baptism record an admin filed on the member’s behalf via submitAsAdmin’s optional memberId)
— those forms have no member-facing fill surface at all, and a subject shouldn’t be able to fetch or edit that
record just because they’re linked to it. A known, accepted limitation: editing a FILE answer to replace it does
not clean up the old file from Cloudinary — its FormFieldAttachment tracking row was already deleted at the
original submit time, and no resourceType is available at edit time to delete it correctly; judged too narrow an
edge case to justify redundantly storing resourceType in the answer shape or a fragile Cloudinary lookup.
FILE fields (upload-then-reference): the three submit endpoints stay pure JSON — a file is uploaded first, to
its own POST .../fields/:fieldId/attachment endpoint (member/public/admin variants, each gated by the same
visibility/audience-group rules as that audience’s own submit path, via FormSubmissionService.uploadAttachment),
which uploads to Cloudinary (form-submissions folder) and returns { url, publicId }. That object becomes the
FILE field’s answer in the normal submit call; validateAnswers checks only that it’s a well-formed {url, publicId} shape, not a format like EMAIL/NUMBER/DATE. Each upload also writes a FormFieldAttachment
tracking row (formId, fieldId, publicId, url, resourceType) — pure bookkeeping, not a real relation to
Form/FormField (plain UUID columns, deliberately no @ManyToOne, to avoid resurrecting the Form/FormField
cyclic-dependency issue documented on Form.fields for no benefit). On a successful submission,
saveSubmission deletes the tracking row for every FILE answer actually referenced (awaited, not fire-and-forget,
since a silent failure here would let the row survive to the sweep below and delete a file a real submission still
relies on). FormAttachmentCleanupScheduler (same forEachActiveTenant shape as SocialMediaRetentionScheduler)
sweeps nightly (0 4 * * *) for tracking rows older than 48h — a row’s mere continued existence past that window
is the signal the upload was abandoned, since a claimed one is deleted immediately — and deletes both the row
and the Cloudinary asset. Upload size is capped by the new MAX_FORM_ATTACHMENT_UPLOAD_MB platform setting
(DynamicLimitedFileInterceptor, same convention as cover/logo/class-material/finance-proof uploads).
| Method | Route | Auth | Notes |
|---|---|---|---|
| GET | /forms/audience-groups/lookup |
AdminGuard (FORMS_WRITE) | {id, name}[] of every Contact List, for the audience-restriction picker. Own route + gate rather than reusing GET /groups/lookup (gated on ANNOUNCEMENTS_WRITE) — a forms admin shouldn’t need a second, unrelated permission grant |
| GET | /forms/options |
AdminGuard (FORMS_READ) | Unfiltered, unpaginated {id, title, fields: {id, pageIndex}[]}[], for a “pick a form to embed” dropdown (Pages’ Registration-section editor) — a picker can’t paginate a single-select, so this stays “return everything” even though GET /forms below no longer does |
| POST | /forms |
AdminGuard (FORMS_WRITE) | Create a form with its fields in one call. Optional purpose/oneResponsePerMember/opensAt/closesAt/timeLimitMinutes/revealScoreImmediately (see Quiz + Voting / Time-boxing, above); a field’s correctOptions only takes effect when purpose: 'QUIZ' |
| GET | /forms?page=&limit=&search=&purpose=&visibility=&status= |
AdminGuard (FORMS_READ) | Paginated + filtered (FormService.listForms) — see below for why this changed from the earlier unpaginated find(). search is ILIKE across title/description; purpose/visibility are exact-match; status is ACTIVE|INACTIVE (maps to isActive). Response is PaginationResponseDto<Form>, not a bare array |
| GET | /forms/:id |
AdminGuard (FORMS_READ) | Get one form with fields |
| PATCH | /forms/:id |
AdminGuard (FORMS_WRITE) | Update form + diff-sync fields (see above). audienceGroupId/dedupFieldId/nextStepsFieldId/postSubmitMessage/generalActionUrl/generalActionLabel all follow the same “explicit null clears, omit to leave untouched” convention as eventId. postSubmitOutcomes follows it too, but replaces the whole array wholesale rather than diff-syncing per-outcome (see Ranked conditional overrides, above) |
| DELETE | /forms/:id |
AdminGuard (FORMS_WRITE) | Cascades fields + submissions |
| POST | /forms/:id/clone |
AdminGuard (FORMS_WRITE) | Clone a form — { title, ... } (see CloneFormDto; title is the only required field). Clone starts isActive: false with no cover/logo, fields copied verbatim with fresh ids, dedupField/nextStepsField re-matched by label. Never clones submissions |
| POST | /forms/:id/cover |
AdminGuard (FORMS_WRITE) | Multipart, field name cover. Sets Form.coverImageUrl |
| DELETE | /forms/:id/cover |
AdminGuard (FORMS_WRITE) | Clears the cover image |
| POST | /forms/:id/logo |
AdminGuard (FORMS_WRITE) | Multipart, field name logo. Sets Form.logoUrl |
| DELETE | /forms/:id/logo |
AdminGuard (FORMS_WRITE) | Clears the logo |
| POST | /forms/:id/submissions |
AdminGuard (FORMS_WRITE) | Admin records a submission on someone’s behalf — { answers, memberId? }. Works against any visibility, not just ADMIN_ONLY (e.g. backfilling a MEMBERS-visibility form entry for someone who called in). Returns { submissionId, nextSteps }, same shape as the member/public submit endpoints |
| GET | /forms/:id/submissions?sortBy= |
AdminGuard (FORMS_READ) | Paginated (?page=&limit=) — this list is attendance-scale, unlike the forms list itself. sortBy=score (QUIZ leaderboard) sorts score DESC NULLS LAST, createdAt DESC via a query builder instead of the default createdAt DESC |
| GET | /forms/:id/submissions/export |
AdminGuard (FORMS_READ) | CSV, one column per field (ordered), Submitted By shows the member’s name or “Public”. A FILE field’s cell is the uploaded file’s URL |
| GET | /forms/:id/analytics |
AdminGuard (FORMS_READ) | At-a-glance summary across all submissions, computed per field type (see below) |
| POST | /forms/:id/fields/:fieldId/attachment |
AdminGuard (FORMS_WRITE) | Multipart, field name file. Same shared upload path as the member/public equivalents below (see FILE fields, further down) — lets an admin attach a file while recording a submission via POST /forms/:id/submissions |
| GET | /forms/member |
JwtAuthGuard | Forms visible to the caller (isActive, MEMBERS or PUBLIC) — optional ?eventId= filter. A MEMBERS form with an audienceGroup is filtered out for anyone outside that Contact List |
| GET | /forms/member/:id |
JwtAuthGuard | Form fields + suggestedValues auto-filled from the caller’s own profile, plus windowState (OPEN/NOT_OPEN_YET/CLOSED), attempt ({startedAt, expiresAt}|null, a timed QUIZ’s in-progress attempt if any), and attemptCount (how many times this member has already submitted this form — pair with the returned form.oneResponsePerMember to show whether retakes are allowed). 404s (not 403) if the form has an audienceGroup the caller isn’t in |
| GET | /forms/member/history?purpose= |
JwtAuthGuard | The caller’s own past QUIZ/VOTE submissions, most recent first, across every form regardless of whether it’s still active — {formId, formTitle, formPurpose, submittedAt, score, maxScore, choice}[], paginated. Optional ?purpose=QUIZ|VOTE narrows it. Must be registered before :id or history would be swallowed as a form id |
| POST | /forms/member/:id/start |
JwtAuthGuard | Starts (or resumes) a timed QUIZ’s per-attempt countdown — FormAttemptService.startOrGetAttempt. Returns {startedAt, expiresAt}. 400 if the form isn’t a QUIZ with timeLimitMinutes set, or already completed with retakes disallowed |
| POST | /forms/member/:id/submit |
JwtAuthGuard | memberId comes from the token, never the body. Returns { submissionId, nextSteps, score?, maxScore?, scorePendingUntil? } — the score fields are QUIZ-only (see Quiz + Voting, above) |
| GET | /forms/member/:id/submission |
JwtAuthGuard | The caller’s own most recent submission for this form — { submissionId, answers, editable, score?, maxScore?, scorePendingUntil? }. Powers the “edit your response” flow off a DUPLICATE_SUBMISSION error, and reveals a withheld QUIZ score once its window has closed. 404s on an ADMIN_ONLY form even for a linked member |
| PATCH | /forms/member/submissions/:submissionId |
JwtAuthGuard | Edit the caller’s own submission — { answers }, same shape as submit. 400 if Form.editableAfterSubmit is off; 404 if the submission isn’t the caller’s or the form is ADMIN_ONLY |
| POST | /forms/member/:id/fields/:fieldId/attachment |
JwtAuthGuard | Multipart, field name file, max size MAX_FORM_ATTACHMENT_UPLOAD_MB. Returns { url, publicId } — the answer value for a FILE field in the submit call above. Subject to the same MEMBERS/PUBLIC visibility + audience-group gating as submit |
| GET | /forms/public/:id |
Public, 404 unless isActive && visibility === PUBLIC |
No tenant subdomain restriction beyond the usual Host-header resolution. Response is a sanitized PublicFormDto — every field’s optionMetadata is stripped |
| POST | /forms/public/:id/submit |
Public, rate-limited (5/min) | memberId is always null — an open, unauthenticated write endpoint, throttled from day one rather than retrofitted. Returns { submissionId, nextSteps } |
| POST | /forms/public/:id/fields/:fieldId/attachment |
Public, rate-limited (5/min) | Multipart, field name file. Same upload-then-reference contract as the member endpoint, no member identity involved |
forms is a toggleable module (KNOWN_MODULES, ModuleEnabledGuard) and Pro-plan-gated
(@RequiresPlan(PlanFeature.FORMS), PlanGuard) on all three controllers, including the public one — PlanGuard
keys off the tenant resolved by TenantMiddleware, not the caller’s auth, so an unauthenticated visitor filling out
a public form on a Free-tier tenant is still correctly blocked. Both gates are independent: a Pro tenant can still
disable Forms via the module toggle, and a Free tenant sees 403 PLAN_UPGRADE_REQUIRED regardless of the module
toggle’s state.
Analytics (GET /forms/:id/analytics, a Google-Forms-style summary, not raw rows): computed in-memory per
field from every submission’s answers[fieldId], shaped by that field’s type — DROPDOWN/CHECKBOX get a
per-option {count, percentage} breakdown (CHECKBOX counts every selected value, since one submission can pick
several options); NUMBER gets {average, min, max}; FILE gets {uploadCount} (same number as
responseCount — a {url, publicId} answer isn’t a meaningful “sample” the way free text is); every other type
(TEXT, EMAIL, PHONE, TEXTAREA, DATE) gets up to the 20 most recent non-blank answers as sampleAnswers,
since there’s no meaningful aggregate for free text. Blank/null/undefined answers are excluded from
responseCount and every computation — a field added after some submissions already exist doesn’t drag its
stats toward zero.
GET /forms moved from an unpaginated client-filtered list to server-side pagination/search (FormService. listForms), reversing an earlier decision recorded in this doc. The original reasoning (admin-authored reference
data like departments/event-configs doesn’t grow unboundedly, so backend pagination just trades an instant
zero-network filter for a round-trip per keystroke) held for Forms before Quiz + Voting — but a QUIZ/VOTE
form is created far more often than a STANDARD one ever was (a weekly sermon quiz, a recurring vote), so Forms
now behaves like games/volunteer_opportunities/small_groups (which already made this same move — see
their own admin-list search/filter) rather than like departments. search is ILIKE '%term%' across title/
description, backed by IDX_forms_title_trgm/IDX_forms_description_trgm (same trigram-index pattern as those
three modules); purpose/visibility/status are exact-match, backed by IDX_forms_purpose and the
pre-existing idx_forms_visibility/idx_forms_is_active. app/forms/page.tsx’s search box and Visibility/
Status/Purpose filters now debounce and re-fetch page 1 server-side (matching app/games/page.tsx’s own
debounce pattern) instead of filtering an already-fetched array, and the list gained a PaginationBar.
A form picker can’t paginate a single-select, though — Pages’ Registration-section editor (sections-editor. tsx) still needs every form to populate its dropdown. GET /forms/options (FormService.getFormOptions,
useFormOptions() in discuva-admin) exists for exactly that: unfiltered, unpaginated, and deliberately lighter
than a full FormRecord (just {id, title, fields: {id, pageIndex}[]}, enough for the picker’s own
formPageCount warning) so it stays cheap to fetch on every Pages-editor load regardless of how large Forms
grows.
discuva-admin UX: a “More Options” disclosure on each field. A client-side addition — app/forms/ field-editor.tsx’s per-field editor gained a collapsible “More Options” section (helper text, length/selection
bounds, the validation pattern, and the conditional-visibility rule) — collapsed by default with a small dot
indicator when a field already has any of that configured, so a form with several fields doesn’t turn into a
long scroll of mostly-unused optional settings. The field’s actual content (label, type, and its options for
DROPDOWN/CHECKBOX) stays always visible — only the advanced/optional settings collapse.
Filter <select> styling, reported live as looking out of place — the Visibility/Status filters initially
used the browser’s native <select> chevron, which clashed against the custom-styled search box right next to
it. Every other <select> elsewhere in discuva-admin uses appearance-none to strip that native arrow, but
none of them replace it with anything, leaving a box with no visible dropdown indicator at all — not a pattern
worth copying as-is. Fixed with appearance-none plus an actual ChevronDown icon positioned absolutely inside
a wrapping relative div (pointer-events-none so it doesn’t intercept the click) — a small, deliberate
improvement on the app-wide convention rather than a match to it, applied here and to the equivalent filter on
the Pages list below.
Pages (src/pages/)
Per-church public web pages — a homepage or a shareable landing page (e.g. a conference page), assembled from a
fixed library of section types rather than a free-form drag-and-drop canvas, mirroring the Forms builder’s own
“admin assembles typed, ordered items” shape. A Page has a unique-per-tenant slug (url-safe, ^[a-z0-9-]+$),
a title, an isPublished flag (only a published page is ever reachable publicly — an unpublished draft 404s
identically to an unknown slug, so a visitor can never distinguish “never existed” from “not live yet”), optional
seoDescription/ogImageUrl for link-preview metadata, and an ordered sections: PageSection[] ({ id, type, content }, plain jsonb, whole-array replace on every save — same convention Form.postSubmitOutcomes uses,
since there’s no per-section DB row to diff against). id is client-generated (a uuid), not server-assigned —
sections have no relation of their own for TypeORM to assign an id to.
Reusable Page templates share the tenant-scoped builder_templates table described under Forms.
GET/POST /pages/templates and DELETE /pages/templates/:templateId are guarded by PAGES_READ for reads
and PAGES_WRITE for mutations, along with the Pages module gate. Save bodies use
{ name, description?, data: object }; the admin builder applies a stored Page as a new draft with fresh
section ids. These endpoints do not publish or modify an existing Page.
Draft/publish split (AddPageDraftFields1796540400000) — title, seoDescription, ogImageUrl/
ogImagePublicId, and sections each have a draft* counterpart (draftTitle, draftSeoDescription,
draftOgImageUrl/draftOgImagePublicId, draftSections). PageAdminController.update (PATCH /pages/:id)
writes only the draft* columns for these four fields — an already-published page can be edited freely, any
number of times, without a single PATCH changing what a visitor sees. slug and isPublished are the
exception: both keep writing their live columns immediately, same as before PATCH gained this split (slug is a
URL/identity concern, not content; isPublished is a reachability switch, not content either). POST /pages/:id/publish (PageService.publish) is the only thing that copies draft* onto the live columns — it
also sets isPublished = true, so it doubles as “publish this for the first time” and “push a pending edit
live” in one action. create() still sets live and draft fields to the same submitted values, since nothing
live exists yet to protect for a brand-new page.
The one correctness-sensitive detail: setOgImage/removeOgImage (POST/DELETE /pages/:id/og-image) now
write draftOgImagePublicId, and only delete the replaced Cloudinary asset when that replaced id isn’t also
the current live ogImagePublicId — otherwise uploading a new draft image would delete the asset the
published page still points at, before that draft was ever published. publish() does the mirror-image cleanup:
after copying draft onto live, it deletes the previous live OG image from Cloudinary if publishing actually
swapped it for a different one (safe there — nothing else can be pointing at it once that save commits).
Shareable preview links — every Page also has a previewToken (character varying UNIQUE, DB-default
gen_random_uuid()::text, generated once per page and never rotated in v1). GET /pages/public/:slug/preview?token=... (PageService.getForPreview) returns the same PublicPageDto shape as
the live public route, sourced from the draft* fields instead — no isPublished check, so a page that’s never
been published at all is still previewable. A missing/wrong token 404s identically to an unknown slug, same
“don’t reveal which reason” posture the live route already takes for unpublished-vs-nonexistent. The token is
the only gate: this is a bearer-link model (discuva-admin’s “Preview” button builds
<liveUrl>?previewToken=<token>), not an authenticated one — anyone holding the link can view the draft, same
tradeoff a Figma/Google Docs “anyone with the link” share carries.
Page-level theme + accent/background color (AddPageThemeFields1796713200000,
AddPageBackgroundColor1796886000000) — theme (character varying, default 'minimal'), accentColor, and
backgroundColor (both nullable hex strings), each with a draft* counterpart following the exact same
draft/publish routing as every other content field above: PATCH writes draftTheme/draftAccentColor/
draftBackgroundColor only, publish() copies all three onto the live columns. theme is a whole-page choice,
not per-section — 'minimal' is the original look (unchanged) and every page defaults to it; 'bold' is a
church-picked background (not a fixed color — the original single hardcoded dark background was a real gap for
a multi-tenant product, where every church has its own brand) with bold/uppercase headings. backgroundColor
null falls back to the original default (#150a08, discuva-member’s bold-theme.ts DEFAULT_BOLD_BG), and is
only meaningful under 'bold' — there’s no analogous concept under 'minimal''s plain white background, so
discuva-admin’s editor keeps the backgroundColor picker gated behind theme === 'bold' and nulls it on save
otherwise. accentColor, by contrast, applies under either theme — a 'minimal' (light) page can pick a
brand accent color too (used more sparingly there: stat numbers, the active FAQ question, buttons, the Speakers
“Host” badge, Countdown digits — there’s no dark background for it to stand out against the way it does under
'bold'). This wasn’t always true: accentColor used to be gated behind theme === 'bold' in both
discuva-admin’s editor (hidden picker, nulled on save) and discuva-member’s renderer (dark && guards in
section-renderer.tsx/faq-accordion.tsx/countdown-timer.tsx) — a real gap for a multi-tenant product, since
a church running the default light look had no way to apply its brand color anywhere. Both colors validated at
the DTO layer only (@IsIn(['minimal', 'bold']), @IsHexColor() ×2), not in assertValidSections — neither is
per-section content.
Rendering lives entirely in discuva-member (bold-theme.ts + SectionRenderer): resolveBoldPalette computes a
full set of CSS custom properties (--bold-bg, --bold-fg, --bold-fg-NN at several opacity steps,
--bold-border-NN) from backgroundColor once, applied as an inline style on the page’s outer wrapper
(app/p/[slug]/page.tsx) — foreground text color is never stored, it’s picked (white vs. near-black) from the
background’s YIQ luminance so whatever a church picks stays legible without them needing to reason about
contrast themselves. Every section references these by name (text-[var(--bold-fg-60)], bg-[var(--bold-bg)])
instead of hardcoding text-white/text-white/60 — Tailwind compiles that class fine at build time (the class
string never changes, only what the variable resolves to), which is what lets one church-picked color cascade
into every section with no prop threading. accentColor remains a real runtime value applied via inline
style={{ color / borderColor: accentColor }} (a literal var() string can’t be used there since it’s an
admin-controlled hex, not a fixed CSS variable) — never a Tailwind bracket class, since Tailwind’s JIT can’t
statically extract a class from a value only known at render time.
Page-level font (AddPageFontFamily1796972400000) — fontFamily/draftFontFamily (nullable character varying), same draft/publish routing as theme/accentColor/backgroundColor: PATCH writes draftFontFamily
only, publish() copies it onto the live column. Validated against a small curated list, PAGE_FONTS = ['inter', 'poppins', 'playfair', 'bebas-neue'] (@IsIn, not free text) — next/font/google needs a statically-imported
specifier to self-host/preload a font, which rules out an arbitrary runtime string the way accentColor’s hex
value works; a short fixed list sidesteps that entirely. Page-level only, not per-section, for the same reason a
page-builder that let every block pick its own typeface would look amateurish rather than flexible. null keeps
the exact font-sans look every page already had — a font only ever applies once a church explicitly picks one
from discuva-admin’s Font <select> (app/pages/page.tsx, next to Theme); there’s deliberately no per-theme
default that would silently change an already-published page’s typography as a side effect of this feature
shipping. Rendering (discuva-member’s components/pages/page-fonts.ts) statically imports all 4 fonts via
next/font/google and picks one’s .className (not the CSS-variable .variable pattern app/layout.tsx’s own
root fonts use — a page needs exactly one font applied to its whole subtree at a time, so there’s no need for
several fonts coexisting via CSS variables) onto the page’s outer wrapper in app/p/[slug]/page.tsx, overriding
the inherited body font via normal CSS specificity. Bebas Neue only ships at weight 400 on Google Fonts, so a
section elsewhere requesting font-bold under it renders at that same weight (a browser fallback, not a bug).
Per-section style overrides (SectionStyleDto, PageSectionDto.style) — each section in sections/
draftSections may carry an optional style: { align?, columns?, size?, accentColor?, spacing?, layout? }
sibling to content, validated as a real nested class (@ValidateNested() + @Type(() => SectionStyleDto))
rather than a bare @IsObject() the way content is — unlike content, whose shape depends on type, style’s
shape is fixed regardless of section type, so a shared DTO validates it directly instead of going through
PageService.assertValidSections’s per-type switch. align ∈ ['left', 'center', 'right'], columns ∈
[1, 2, 3, 4], size ∈ ['sm', 'md', 'lg', 'xl'], accentColor a hex string overriding the page-level one for
just that section, spacing ∈ ['sm', 'md', 'lg'], layout ∈ ['stacked', 'split']. Validated structurally
only — this DTO does not know or enforce which fields apply to which PageSectionType; that per-type
applicability table is owned entirely by discuva-admin’s SECTION_STYLE_APPLICABILITY
(app/pages/sections-editor.tsx), kept in exactly one place to avoid two authorities drifting out of sync.
spacing is the one field with no applicability gating at all — it applies to every section type. layout is
REGISTRATION-only today:
| Type | align | columns | size | accentColor |
|---|---|---|---|---|
| HERO | text block | — | subtitle body text | CTA button |
| ABOUT | stacked layout only (meaningless once layout: 'split' already anchors text to one side) |
— | body text (applies in both stacked and split — body copy exists in both) | — |
| STATS | — | 1–4 | value text size | value color |
| SPEAKERS | — | 1–4 | photo tile size (independent of columns — caps the tile’s own footprint, not how many share a row) |
host badge + regular-tile badge |
| SCHEDULE | — | 1–4 | — (day cards are information-dense; a shrink knob risks overflow) | label/icons |
| REGISTRATION | stacked: heading/body text + the white form card’s own position. split: which side the form card sits on (see layout below) — either way the card’s contents (EmbeddedFormFill) stay untouched |
— | body text (both stacked and split) | — (the embedded form card is deliberately theme-independent; out of scope) |
| TESTIMONIALS | — | 1–3 (not 4 — a quote card needs real width) | quote text | — |
| FAQ | — | — | heading + question/answer text, scaled together as one choice | active question |
| MERCH | image+CTA block (coupled with size — alignment is only visible once size caps the image narrower than the section) |
— | image width cap | CTA button |
| COUNTDOWN | row justify | 1–4 | digit size | digit color |
| FOOTER | text block | — | footer text | links/social links |
| GALLERY | — | 1–4 | — | — |
| CHURCH_CALENDAR | — | — | — | entry card border/icons |
| LIVE_NOW | row justify (centers the live badge / offline text) | — | — | live badge border |
spacing — vertical breathing room above/below a section, universal across every type (added after real user
feedback: “the space between the form and the stats counter is too much,” and a request that it be
customizable, not just globally reduced). Unlike the other 4 fields, this isn’t per-type-gated at all —
discuva-admin’s SectionStyleControls renders the Spacing control unconditionally on every section card, and
SectionStyleControls itself can no longer return null for that reason. sm/md/lg map to py-8/py-16/
py-24 (discuva-member’s spacingClass, components/pages/section-style.ts) — md (py-16) is the exact flat
value every section used before this knob existed, so an unset/omitted spacing renders byte-for-byte identical
to every already-published page. HeroSection is the one exception — its vertical rhythm is driven by
aspect-video (when it has a background image) or a responsive py-6 sm:py-12 md:py-16 content overlay (when it
doesn’t), neither of which is a flat padding value a spacing knob could meaningfully replace, so Hero doesn’t
carry this knob.
SECTION_PADDING_X — every section’s own horizontal gutter (components/pages/section-style.ts): px-6 sm:px-8 lg:px-12, replacing a flat px-6 that every section used at every breakpoint. Fine on a narrow phone
(24px), but on a ~1024px-wide tablet the same flat 24px read as almost no margin at all relative to how wide the
content block actually is — confirmed against a real screenshot of the yfc-2026 page, text running close enough
to the viewport edge to look unfinished. Not exposed as a per-section style knob like spacing — there’s no
scenario where an admin would want a different gutter on one section than the rest, so this is one shared,
unconditional constant (SECTION_PADDING_X) every section pulls from, not a lookup keyed by a field on
SectionStyle. HeroSection keeps its own separate px-4 sm:px-6 content-overlay padding (the no-image case) —
a deliberately different, narrower value from before this fix, left untouched since it wasn’t the pattern flagged.
Hero’s mobile sizing, below sm (640px), with a background image: min-h-[85vh] sm:min-h-0 sm:aspect-video,
not a flat aspect-video at every width. A portrait phone viewport crops a 16:9 box down to a short, squat strip —
confirmed against the real yfc-2026 page next to a reference site’s immersive full-screen mobile hero, on a
430×932 viewport matched to the user’s own DevTools screenshot. sm:min-h-0 clears the min-height back out at
sm: and up so the original aspect-video behavior takes over unopposed on tablet/desktop, unchanged. The
no-image case (py-6 sm:py-12 md:py-16 content overlay) is untouched — this only affects Hero sections with a
background image set.
backgroundImageUrlMobile (HeroContent.backgroundImageUrlMobile, structurally validated the same as
backgroundImageUrl in assertValidSections, no dedicated migration — it’s a sibling key inside the existing
jsonb content, not a typed column) — an optional portrait variant of the Hero background image, used only
below sm. min-height is driven purely by viewport height, decoupled from width, so on a tall narrow phone
min-h-[85vh] pushes the box into a much taller/narrower aspect ratio than a landscape backgroundImageUrl
actually has, forcing object-cover to crop hard off the sides. A dial-back to min-h-[65vh] was tried first to
reduce the crop, and did — verified via screenshot, the title text was no longer clipped — but the user weighed in
that logos elsewhere in the same flyer image were still being cropped out, and preferred keeping the fuller 85vh
immersive height with a real fix for the image itself. backgroundImageUrlMobile is that fix: when set,
discuva-member renders it (sm:hidden) instead of backgroundImageUrl below sm, and backgroundImageUrl
(hidden sm:block) above it — two <Image fill priority> elements rather than one, so mobile fetches only the
image actually shown at that width… except both are still requested eagerly regardless of which one is visible,
since CSS display: none doesn’t stop the underlying <img> tag’s own fetch the way a real <picture>/<source media> swap would — a deliberate simplicity tradeoff over building true conditional fetching, revisit if this
page’s LCP becomes a real concern.
First cut of backgroundImageUrlMobile still used min-h-[85vh] for its box, and still cropped. A real
1080×1350 (4:5) upload was still cropped ~16% off each side on a 430×932 phone — min-h-[85vh] (792px tall) is a
narrower/taller box (aspect ≈0.54) than a 4:5 image (0.8) regardless of which image fills it, since min-height
is a viewport measurement with no relationship to the uploaded image’s own dimensions. Confirmed the fix has to be
about the box, not the image: below sm, once backgroundImageUrlMobile is set, the section now sizes itself
via aspect-[4/5] instead of min-h-[85vh] — the box’s shape comes from the image’s own ratio, so a correctly
proportioned upload renders edge-to-edge with zero crop on any phone width, not just the one it happened to be
tested against. sm:aspect-video still takes over unopposed at tablet/desktop. Verified against the real
yfc-2026 page’s own uploaded 1080×1350 image at 430×932: measured section box was exactly 430×537.5 (ratio
0.800, matching 4:5 to three decimal places) and the screenshot showed both corner logos and all text rendering
completely uncropped. Falls back to the original min-h-[85vh]/single-image/object-cover behavior whenever
backgroundImageUrlMobile is unset — no change for any page that hasn’t set one. discuva-admin’s Hero editor
(sections-editor.tsx) states the 4:5 ratio as a requirement, not a suggestion, in its upload hint — since the
box now takes its shape directly from it, an off-ratio upload is the one remaining way to still get cropped.
Needs @ValidateNested()/@Type() specifically because the global ValidationPipe’s whitelist: true
(main.ts) would otherwise silently strip a plain object literal here before validation even runs;
forbidNonWhitelisted: true means an unrecognized key inside style (e.g. a typo) 400s rather than being
silently dropped. Stored as an opaque jsonb sibling to content on the entity side (Page.sections’s
PageSection.style), requiring no migration of its own since sections already live in a jsonb array.
Rendering (discuva-member, components/pages/section-style.ts + SectionRenderer) — a section’s own
style.accentColor, when set, wins over the page-level accentColor for just that section
(SectionRenderer’s effectiveAccentColor). columns maps to a columnBasisClass(columns, variant) lookup —
three separate literal-string tables (compact for Stats/Countdown, roomy for Schedule, square for Speakers’
override path), each a Tailwind-JIT-safe literal string (never built via runtime interpolation, since Tailwind’s
JIT only scans literal strings present in source). Each entry pairs an unprefixed min-w/max-w hint (today’s
original, content-driven mobile wrapping, unchanged) with an sm: override that actually forces the requested
column count from 640px up — sm:min-w-0 sm:max-w-none clear the mobile hint, sm:grow-0 sm:shrink-0 sm:basis-[calc(...)] (or sm:basis-full for columns: 1) sets a fixed, non-growing width equal to exactly
1/columns of the row (minus that row’s own gap, split proportionally) — flexbox decides how many items share a
line using this basis before grow/shrink is applied, which is what makes this a real guarantee rather than a
hint. (A first version used only the unprefixed min-w/max-w hint and shipped looking correct in isolation, but
real-browser screenshot verification caught that it doesn’t actually cap items per row — e.g. a “columns: 2”
Stats row rendered all 4 stats on one line at tablet/desktop width, because a min-width hint alone never stops
extra items from sharing a line once the container is wide enough.) size always replaces whatever
count-based auto-sizing a section already had (Stats’ statValueSizeClass), never blends with it, via a
responsive class pair per bucket (e.g. xl → text-5xl sm:text-7xl) — Countdown has no existing auto-sizing to
preserve, so its size is a plain override. Speakers’ size (tile footprint) and columns (row-sharing cap)
are independent and combinable — a fixed-size tile still respects a columns cap on its wrapper, they aren’t
mutually exclusive. align maps to text-left/center/right (block sections) or justify-start/center/end
(Countdown’s row). Testimonials’ columns (1–3) maps to a plain CSS Grid instead of flex-wrap — quote cards are
block-shaped and don’t have the “partial last row” centering concern the flex-wrap sections solve for.
Speakers’ flex-wrap migration is opt-in, not a default-path change — the existing grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 is an explicit named-breakpoint contract every Speakers section without a style
override still relies on; flex-wrap’s wrap point is driven by cumulative width math, not named breakpoints, and
isn’t guaranteed to reproduce the same 2/3/4 cadence. Rather than risk regressing every existing section, the
grid stays byte-for-byte untouched whenever neither columns nor size is set; flex-wrap only activates once a
page actually opts into an override — new behavior nobody currently depends on. Verified via real Playwright
screenshots at 375px/768px/1280px against a live throwaway Docker stack (DB-backed, not simulated).
Hiding a section without deleting it (PageSectionDto.hidden) — each section may carry an optional
hidden?: boolean sibling to content/style, validated as a plain @IsOptional() @IsBoolean() (no per-type
meaning, unlike style). A hidden section stays fully saved — content, style, its position in the list — but
PageService.getForPublic/getForPreview both filter it out of the sections array they return
(withoutHiddenSections, applied after withApprovedTestimonials), so discuva-member never receives it at all
and needs no changes of its own to honor this. Filtering happens in both routes, not just the live one — a
preview that showed a section publishing would actually hide wouldn’t be previewing the real outcome. Distinct
from removing the section outright (sections.filtering it out of the array client-side): a church building out
next month’s section ahead of time, or temporarily pulling one down, keeps its content and doesn’t need to
rebuild it from scratch later. PageAdminController’s own GET /pages/:id (the builder’s raw read) is
unaffected — it returns every section, hidden or not, since the admin needs to see and un-hide them.
discuva-admin’s SectionsEditor renders an eye/eye-off toggle per section card (dimmed + a “Hidden” badge when
on) — no separate confirm dialog, unlike removing a section.
Duplicating an existing page (POST /pages/:id/duplicate, PageService.duplicate) — starts a brand-new page
from an existing one’s current draft (not live — the most up-to-date working version, same reasoning
discuva-admin’s openEdit always continues from draft*). Takes a required new slug (pages have no natural
“copy” slug to auto-generate, and slugs must stay unique) and an optional title override, falling back to
"<source title> (Copy)". Always starts unpublished, regardless of the source page’s own isPublished state
— a duplicate under a fresh, unreviewed slug must never silently go live just because the page it was copied from
happened to be. Sections are deep-cloned (JSON.parse(JSON.stringify(...))), not shared by reference — the two
pages are genuinely independent from the moment of creation, and re-validated (assertValidSections) before
saving, the same defensive check publish() already applies, in case something the draft references (e.g. a
REGISTRATION section’s formId) was deleted since the source was last saved. Neither OG image is carried
over — sharing one Cloudinary asset’s public_id across two Page rows would let either page’s own
image-replace/remove flow delete an asset the other still points at; the duplicate simply starts with no OG
image, same “no orphan-cleanup, accepted simplicity” tradeoff already made elsewhere in this service.
previewToken is not copied either — the DB default on the new row generates its own, since every page needs an
independent, private preview link. discuva-admin surfaces this as a “Duplicate Page” button in the editor panel
(prompts for the new slug, then opens the created page for editing) — see handleDuplicate in app/pages/page.tsx.
Section toolkit (PageSectionType, 14 fixed types) — content’s shape depends on type:
| Type | Content shape |
|---|---|
HERO |
title, subtitle?, dateRangeText?, backgroundImageUrl?, ctaLabel?, ctaUrl? (ctaLabel/ctaUrl paired — both or neither) |
ABOUT |
heading, body, imageUrl?, layout? ('stacked' | 'split'), imagePosition? ('left' | 'right'). layout defaults to 'stacked' (image above centered text, unchanged); 'split' is a two-column layout (text one side, image filling the other — falls back to 'stacked' client-side if there’s no image to split against). imagePosition only matters when layout is 'split', defaulting to 'right' |
STATS |
items: { label, value }[] (≥1) |
SPEAKERS |
heading?, items: { name, title?, photoUrl?, isHost? }[] (≥1). At most one item should be isHost: true — rendered as a larger, featured card above the regular grid instead of inside it (not validated server-side, same “harmless if malformed” precedent HERO’s hideOverlayText sets: if more than one item claims it, only the first counts, the rest fall back into the grid) |
SCHEDULE |
heading?, days: { label, date?, venue?, entries: { time?, title }[] }[] (≥1 day, each with ≥1 entry). venue is per-day, not per-section — a multi-day programme can move locations day to day. Rendered as a bordered card grid (discuva-member’s ScheduleSection), one card per day |
REGISTRATION |
heading?, body?, formId, ctaLabel?, hideFormMeta? — embeds an existing Form inline (rendered client-side via the same FormFillFields/PaginatedFormFillFields components a form’s own public fill page already uses) rather than reimplementing registration. Reuses the whole Forms feature (validation, dedup, notifications, postSubmitOutcomes) for free. hideFormMeta (default off) suppresses the linked form’s own name/description inside the card — see the embedded-form-fill fixes below. A page may carry more than one REGISTRATION section (e.g. event registration and a separate merch pre-order form) — nothing restricts it, each is independent with its own heading/body/formId |
TESTIMONIALS |
heading?, items: { quote, name?, photoUrl? }[] (≥1), acceptSubmissions?: boolean. When acceptSubmissions is on, a visitor can submit their own testimony from the public page (see “Visitor-submitted testimonials” below) — approved ones are merged into items server-side, so items returned by the public/preview routes may be longer than what was saved |
FAQ |
heading?, items: { question, answer }[] (≥1) |
MERCH |
heading?, imageUrl, linkLabel?, linkUrl? (linkLabel/linkUrl paired — both or neither) — a single promotional image/poster plus an optional CTA link, e.g. a merch flyer or a pre-order banner |
COUNTDOWN |
heading?, targetDate, expiredMessage? — a live days/hours/minutes/seconds count down to targetDate, the one section whose content carries a real machine-readable instant rather than free text (unlike HERO.dateRangeText/SCHEDULE’s day labels). targetDate must be a value Date.parse accepts (rejected with a 400 otherwise); discuva-admin’s editor captures it via a datetime-local input and converts it to a full ISO instant with new Date(local).toISOString() at the moment it’s picked, so the stored value is timezone-correct for every visitor with no backend timezone plumbing needed — see that repo’s CountdownContent comment. Rendering (discuva-member’s CountdownTimer) is a "use client" island using useSyncExternalStore (not useState+useEffect) to tick a 1s setInterval against Date.now(), with getServerSnapshot returning null so SSR renders nothing rather than baking in the server’s clock and mismatching on hydration |
FOOTER |
heading?, text?, links?: { label, url }[], socialLinks?: { platform, url }[], showCopyright?, showContactInfo?. platform ∈ FOOTER_SOCIAL_PLATFORMS ('instagram' | 'facebook' | 'youtube' | 'tiktok' | 'x' | 'website') — a small fixed set, not free text. showCopyright defaults to true (the church name + current year); showContactInfo defaults to false (the tenant’s own address/supportEmail, never typed per page — see church below). See “Every page gets a footer” below for why this type is optional and how it interacts with the automatic default |
GALLERY |
heading?, images: { url, publicId?, caption? }[], syncFolderUrl? — a responsive image grid. images is authored directly (like SPEAKERS.items) and required (≥1) when syncFolderUrl is unset; optional/ignorable when it is set, since PageService.withSyncedGalleryImages overwrites it at render time — see “Gallery folder sync” below. Free on any tenant with the pages module enabled, no plan gate |
CHURCH_CALENDAR |
heading?, calendarId — references an existing ChurchCalendar by id (must exist and be isPublished, checked at save time). Does not re-author entries: PageService.withChurchCalendarEntries merges that calendar’s live title/theme/accentColor/entries into content server-side on every getForPublic/getForPreview call, the same “reference another entity, merge its data server-side” pattern REGISTRATION.formId already uses for embedding a Form. Paid-plan gated on PlanFeature.CHURCH_CALENDAR — see “Church Calendar section — plan gating and downgrade handling” below |
LIVE_NOW |
heading?, offlineMessage? — no content to author beyond these two; isLive/sessionInfo are computed fresh on every request (PageService.withLiveStatus, backed by ServiceSessionService.getActiveSessions()), never authored or cached. Free, no plan gate |
Gallery folder sync — “a specified path, just update pictures, always up to date.” GalleryContent. syncFolderUrl (optional) is a public Google Drive folder link — when set, PageService. withSyncedGalleryImages (GalleryFolderSyncService) lists that folder’s images at request time and
overwrites images in the getForPublic/getForPreview response only, never written back to the saved
section, so an admin can keep dropping new photos into the folder each week instead of re-uploading through
the Gallery editor. Deliberately the “no OAuth, no connected account” shape the church calendar/social-media
integrations in this codebase don’t use: a single platform-wide, read-only GOOGLE_DRIVE_API_KEY env var
(not a per-tenant credential, and not TenantYoutubeIntegration’s per-tenant-stored-key pattern) — the folder
itself just needs “Anyone with the link can view” sharing turned on, no admin authorizes anything. Every
failure mode degrades gracefully rather than breaking the page: extractFolderId (a plain
/folders/([a-zA-Z0-9_-]+)/ regex, no live API call) is checked at save time so an admin can save/edit a
syncFolderUrl even with no API key configured yet; at render time, an unset GOOGLE_DRIVE_API_KEY, a
malformed url, a folder that isn’t actually public, a Drive API error, or a network failure all come back as
[] from GalleryFolderSyncService.listImages and the section falls back to whatever images are still
saved (a visitor never sees a broken or empty gallery just because a sync attempt had a bad day). Listings are
cached 10 minutes per folder id (CacheService.getOrSet, key gallery_folder_sync:<folderId>) — long enough
to spare the one shared API key’s quota across every visitor of every tenant using this, short enough that
photos swapped in before Sunday service show up the same morning.
Church Calendar section — plan gating and downgrade handling. CHURCH_CALENDAR is the one section type gated
behind PlanFeature.CHURCH_CALENDAR — a service-level check (PageService.assertChurchCalendarEntitled/
isChurchCalendarEntitled, resolving the tenant’s plan via PlanFeatureResolverService.resolve(tenantId), the
CLS-sourced tenantId the exact same pattern finance-request.service.ts’s own service-level plan check already
uses), not a controller-level @RequiresPlan guard — a single jsonb section type living inside a mixed array of
otherwise-unrestricted sections can’t be gated by a route-level decorator the way a whole endpoint can. The check
runs in two places, for two different reasons:
- At save time (
assertValidSections, alongside thecalendarIdexistence/published check): a non-entitled tenant’s save attempt throwsForbiddenException({message, code: 'PLAN_UPGRADE_REQUIRED', requiredFeature: PlanFeature.CHURCH_CALENDAR})— the exact shape discuva-admin’s axios interceptor already watches for (utils/auth/axios-client.ts) to pop the existing upgrade-required modal, so no new frontend upsell UI was needed for this at all. - At read time (
getForPublic/getForPreview, viawithChurchCalendarEntries): a section saved while the tenant was entitled does not keep working forever after a downgrade. If no longer entitled, the section is dropped from the output array entirely — never returned broken or half-filled — so a visitor never sees a permission error or an empty/awkward gap; the page simply renders as though the section weren’t there. The section’s own savedcontent(itscalendarId,heading) is never touched by this — purely a response-time filter — so resubscribing makes it reappear automatically with zero reconfiguration. The same re-check also covers the referencedChurchCalendaritself losing itsisPublishedflag after the section was saved — either condition failing drops the section the same way.
The admin builder’s own reads (GET /pages and GET /pages/:id, via PageService.getAll/getByIdForAdmin)
are deliberately not filtered this way — an admin editing their own page should never have a section silently
vanish from their own view of it. Instead both responses carry a churchCalendarEntitled: boolean (the same
tenant-wide value attached to every row getAll returns, since entitlement isn’t per-page) so discuva-admin’s
editor can show a small “Requires upgrade — not currently visible to visitors” badge directly on that section’s
card. discuva-admin opens a page for editing straight from the GET /pages list’s own in-memory array, never a
separate per-id fetch — which is why getAll carries the flag too, not only getByIdForAdmin.
HERO and FOOTER are capped at one per page (PageService.assertNoDuplicateSingletonSections, called at the
end of assertValidSections) — unlike REGISTRATION (documented below as deliberately unrestricted, e.g. event
registration alongside a separate merch pre-order form) or any other content-block type, where a second instance
is a real, legitimate use, a second opening banner or a second footer doesn’t represent anything — a page has
exactly one of each, by what they are. Enforced in two places: discuva-admin’s own “Add Section” picker grays
the option out once one already exists (SINGLETON_SECTION_TYPES in sections-editor.tsx), and this server-side
check is defense-in-depth for the API being hit directly (a 400, not a silent drop).
Every page gets a footer, whether or not it has a FOOTER section — a direct feature request (“can we introduce
a footer?”). PublicPageDto/the preview DTO both gained a church: { name, address, supportEmail } field
(PageService.resolveChurchInfo), the current tenant’s own info resolved via ClsService<AppClsStore> +
tenantRepo.findOneBy, sharing the same tenant-branding:${tenantId} cache entry EmailQueueService/
PdfService/TenantCurrencyService already populate (see TenantCurrencyService’s own comment on that shared
key) — a fourth call site of the same established pattern, not a new abstraction. PagesModule gained a plain
TypeOrmModule.forFeature([Tenant]) for this (Tenant is a public-schema, control-plane entity, same reasoning
UtilityModule’s own registration of it documents — not TenantTypeOrmModule). No tenant CLS context (shouldn’t
happen for a real request here) falls back to the CHURCH_NAME env default with no address/email, mirroring
TenantCurrencyService’s own defensive fallback.
discuva-member’s app/p/[slug]/page.tsx renders whichever FOOTER section(s) a page has (if any) pinned to the
very bottom, regardless of where they sit in sections — pulled out of the normal per-section .map() into a
separate pass, since a footer belongs at the end of the page, not wherever an admin happened to drag it in the
builder’s reorderable list (discuva-admin’s own SECTION_TYPE_META.FOOTER.description says this explicitly, so
it’s not a surprise). When a page has no FOOTER section, AutoFooter renders instead — a small, fixed,
non-editable footer (church name + © {year}, nothing else) so a page never just stops abruptly after its last
content section. Adding a real FOOTER section replaces that default entirely with whatever the admin
configures (custom text, links, social links, copyright toggle, contact-info toggle) — there’s no partial-merge
between the two. Nothing stops an admin from adding more than one FOOTER section (same as any other type); all
of them render, in their relative order, rather than silently keeping just one.
socialLinks render as real brand icons, not text labels — a real user-reported gap: the first version rendered
each socialLinks entry as its platform name in plain text (e.g. “Instagram”), which read as an unfinished-looking
placeholder rather than the icon row every other footer on the web uses. lucide-react (the icon set used
everywhere else in this app) deliberately ships no brand/logo icons at all — a trademark-scope decision on their
end, confirmed by checking the installed package’s own icon manifest, not a version mismatch. Rather than pull in
a whole extra icon-library dependency for five icons, discuva-member’s new components/pages/social-icons.tsx
inlines each one as plain SVG path data sourced from Simple Icons (free, CC0-licensed, the standard credible
source for brand marks — the same data every major icon library re-exports under the hood) — InstagramIcon,
FacebookIcon, YoutubeIcon, TiktokIcon, XIcon. 'website' (the one non-brand entry — nothing to show a logo
for) keeps a plain generic icon, lucide-react’s own Globe. Each renders inside a filled circular badge
(FOOTER_SOCIAL_PLATFORM_ICON lookup in section-renderer.tsx): style.accentColor (or the page’s own, via the
same effectiveAccentColor fallback every other section uses) becomes the badge’s background, with
ctaTextColorClass — the same helper MerchSection’s CTA button already uses — picking white or near-black icon
color for contrast; with no accent set, a theme-appropriate neutral fill (bg-white/10 under Bold, bg-[#121212]/5
under Minimal) is used instead. Each link keeps aria-label/title set to the platform’s full name (e.g. “X
(Twitter)”) for accessibility, since the visible content is now icon-only.
Optional page header (Page.showHeader/headerLogoUrl) — off by default, page-level chrome like theme/
accentColor (draft/live split via AddPageShowHeader, a single migration covering both new columns plus
header_logo_url/draft_header_logo_url since none had shipped yet). When on, discuva-member renders a fixed
bar above every page: the church’s own logo (PublicPageDto.church.logoUrl, now added to resolveChurchInfo —
falls back to the LOGO_URL env default the same way TenantInfoController.toProfile() already does) or, once
overridden per-page via headerLogoUrl (e.g. a conference with its own mark distinct from the church’s), that
instead — with the church’s own name as a text fallback when no logo exists at all. Beside it, one link per
section that already has a heading (or title, for HERO) set: no new content field on any section type,
purely a reuse of what’s already there. STATS has no heading field at all, so it never gets a link; FOOTER is
excluded outright (it’s page chrome, not something to jump to).
Active-section highlighting, via IntersectionObserver, not a scroll listener — PageHeader
(components/pages/page-header.tsx) watches every linked section’s own DOM node (already rendered with
id={section.id}) through a thin activation band starting just below the fixed bar
(rootMargin: "-${barHeight}px 0px -70% 0px"); whichever section is inside that band gets its nav link
highlighted (the page’s accentColor when set, a plain strong theme color otherwise). Clicking a link intercepts
the default anchor jump and does its own offset scrollTo, since a plain href="#id" jump has no way to know
about the fixed bar’s height sitting on top of the target — the plain anchor still works with JS disabled, just
without the offset compensation.
position: fixed, deliberately not sticky — this app’s globals.css sets html/body to
overflow-y: auto everywhere, for the authenticated app shell’s hidden-scrollbar look. Having overflow: auto on
both of them breaks position: sticky on any descendant, even though neither element ever actually scrolls
independently (body’s own height already matches its content — the real scrolling happens one level up at
html, which is what makes body’s overflow: auto still count as a scroll container and break sticky’s
containing block). Two attempted fixes for this specific route didn’t work and aren’t worth repeating: a plain
<style> override loses to Next.js’s own managed stylesheet precedence (its data-precedence attribute controls
cascade order independent of DOM position, not source order — confirmed directly, it silently had no effect), and
even an imperative inline-style override (element.style.overflowY = "visible", which should out-rank any
non-!important stylesheet rule) was still overridden — unexplained, and not worth chasing further given fixed
sidesteps the entire question. position: fixed is relative to the viewport, unaffected by any ancestor’s
overflow. The unpublished-draft preview banner renders as part of the same fixed block when a page has a
header (stacked above the nav bar in normal flow, inside the fixed container) rather than as its own separate
sticky element — letting two fixed-position concerns coexist without hand-computing either one’s own top offset.
A ResizeObserver-measured spacer element right after the fixed block reserves the exact real space so page
content is never hidden underneath, self-adjusting live if the banner wraps to two lines on a narrow screen.
Four follow-up fixes from live use of the first version, all in PageHeader/Page.headerLinks:
- Logo bigger and given more room —
h-8(32px) →h-11(44px), with the bar itself growing fromh-14(56px) toh-16(64px) to match; the logo/name block also gained amax-w-[45%]cap so a very long church name (the text fallback) can’t crowd out the nav entirely on a narrow desktop window. navLabel(PageSectionDto.navLabel,PageSection.navLabel) — an optional per-section override of what that section’s link in the header says, e.g."Home"instead of a longHEROtitle. Unvalidated beyond the string type, same posture ashidden;sectionNavLabel()inpage-header.tsxchecks it first and only falls back to the section’s own heading/title when it’s unset, so this is purely additive — no existing page’s nav labels change unless an admin explicitly sets one.- Custom header links (
Page.headerLinks/draftHeaderLinks,HeaderLinkDto) — a plain{ label, url }[]jsonb array (added to the same still-unshippedAddPageShowHeadermigration alongside the columns above, rather than a second migration, since it hadn’t been deployed yet), shown in the nav after the automatic per-section links, always opening in a new tab (target="_blank") regardless of what they point to — same behaviorFOOTER’s owncontent.linksalready has.HeaderLinkDtorequires non-emptylabel/urlon each entry (@IsNotEmpty()), the one place this feature validates content beyond a bare type check.duplicate()deep-clonesheaderLinksthe same way it already doessections, for the same “two genuinely independent pages” reasoning. The storage shape stays exactly this simple — notype/pageIddiscriminant, no server-side resolution — because the “pick one of this church’s other Pages, or type an external URL” experience an admin actually wants lives entirely in discuva-admin’s editor instead (below): picking a Page there just writes that page’s already-known public URL into the same plainurlfield, so this API/DTO/entity needs no awareness that the distinction exists at all. - discuva-admin’s link editor: a Page/Link toggle per link (
app/pages/page.tsx) — “Page” mode shows a<select>of every page this church has (usePages()'s already-loadedpageslist — no new fetch), each labeled by its own title with" (draft)"appended for anything unpublished (still selectable — two pages being built together may need to cross-link before either publishes), and writes that page’s resolved public URL (getTenantMemberAppUrl()+/p/{slug}) straight intourl; “Link” mode is the original plain text input. Purely local editing state (headerLinkModes, index-aligned withheaderLinks, never sent to the backend) — re-opening a saved page infers each link’s mode by checking whether itsurlalready matches one of this church’s own page URLs, defaulting to “Link” otherwise. The one accepted tradeoff of not storing a livepageIdreference: if the target page’s slug is later renamed, a “Page”-mode link saved before that rename goes stale (still points at the old slug) exactly like a “Link”-mode one would if someone changed that same URL elsewhere — deliberately not solved by this feature, in favor of keeping the API surface this simple. - The fixed bar now casts a
shadow-sm, and the spacer reservesbarHeight + 12rather than an exact match — a real reported complaint: on mobile, an edge-to-edgeHEROimage (which has no padding of its own to borrow) sitting flush against the header read as “stuck,” especially with mobile’s already-tight vertical space. The extra 12px isn’tHERO-specific — it’s a universal small gap under the header, benefiting every section a page might open with — and the click-to-scroll offset inhandleNavClickwas updated to match (barHeight + 12) so a clicked link’s target lands with the same breathing room the initial page load already has, not flush against the bar. - Horizontal padding grows with the viewport (
px-6 sm:px-10 lg:px-16, was a flatpx-6) — a real reported complaint on a genuinely wide desktop window: the logo and the last nav link both sat pinned to the literal edge of the browser, which read as unfinished rather than deliberate. A flat padding value that looked fine on a laptop screen just doesn’t scale to a 3000px-wide monitor. - Bug fix: a
navLabelon aFOOTERsection was silently giving it a nav link, contradictingFOOTER’s own “page chrome, not something to jump to” rule (SECTION_TYPE_META.FOOTER.description) — introduced bynavLabelchecking before the typeswitchinstead of after.sectionNavLabel()now excludesFOOTERoutright, before thenavLabelcheck, so setting one there (nothing in discuva-admin’s own editor stopped that) still can’t put it in the nav. A side effect worth naming, not a second bug:STATS— which has no heading field of its own — now can get a nav link if given an explicitnavLabel, since it isn’t specifically excluded the wayFOOTERis; previously it could never appear in the nav at all.
discuva-admin’s Header editor redesigned (app/pages/page.tsx) — four follow-up complaints from live use:
- Visual organization: the whole thing now lives inside one bordered card with a distinct toggle strip at the top, instead of a flat block sitting between Theme and Font with no visual separation of its own.
- A live preview (
HeaderPreview, module-level inpage.tsx) — a small static mock of the real header (logo/ name, nav pills, first one in the accent color) computed from whatever’s currently in the editor:sectionsmapped throughpreviewNavLabel(a duplicate of discuva-member’s ownsectionNavLabel— same “two independent repos” duplicationPageSectionitself already has) plusheaderLinks, so an admin sees roughly what it’ll look like without saving and opening the real preview link. Not pixel-identical to the realPageHeader— no fixed positioning or scrollspy needed for a thumbnail — and the logo falls back throughheaderLogoUrl ?? tenant?.logoUrl(useTenant(), the same tenant-branding context the rest of discuva-admin already reads from) so the preview looks right even before this page has its own logo override set. - A cleaner links editor: collapsed from two stacked rows per link down to one — label, a small icon toggle
(
FileTextfor “Page” mode /ExternalLinkfor “Link” mode, single click cycles between them) that replaced the previous two-button text-pill toggle, the dropdown-or-URL field, and remove, all in one row. - The per-section “Nav label” field is now conditional: only rendered when the page’s header is actually on
(
SectionsEditor/SectionContentEditorboth gained ashowHeaderprop, threaded down frompage.tsx), and never rendered at all forFOOTERsections — matching the same “Footer is never navigable” rule the bug fix above enforces on the rendering side, so the editor doesn’t offer a field that would silently do nothing.
REGISTRATION’s embedded form card (discuva-member, components/forms/embedded-form-fill.tsx) — several
usability fixes, all in this one shared component (not Pages-specific, but only ever mounted from
RegistrationSection):
- The form’s own
titlenow renders as a heading abovedescription. It never did before — onlycoverImageUrlanddescriptionrendered, so a form whose admin had typed a placeholder/test string intodescription(with nothing above it for visual context) read as a stray, unstyled label rather than a proper form name.titleis unconditional (aFormalways has one); its own bottom margin absorbsdescription’smb-5whendescriptionis unset, so the gap before the first field stays consistent either way. align, when set on theREGISTRATIONsection’sstyleandlayoutis'stacked'(the default), keeps each theme’s original default when unset —'bold'still centers the heading/body and card by default,'minimal'still left-aligns, exactly as before this knob existed.textAlignClass’s own “unset → center” fallback is deliberately not reused here for that reason: applying it directly would have silently re-centered every already-published Minimal-themeREGISTRATIONsection the moment this shipped.- Long forms are handled two ways, not just a hint — a real regression risk flagged by a user testing an
actual (short) test form and asking “what happens with a lot of fields”: a hint alone doesn’t stop a long
unpaginated form from visually dominating the page if an admin never acts on it.
- Discoverability, unchanged from the first pass:
PaginatedFormFillFieldsalready becomes a real Back/Next wizard with a progress bar the moment anyFormFieldon the underlyingFormhas apageIndexset — that mechanism already existed and needed no changes. discuva-admin’sRegistrationEditorshows an inline hint (“consider splitting it into steps”) whenever the selected form has ≥6 fields and they’re all still on one page (LONG_FORM_FIELD_THRESHOLD,app/pages/sections-editor.tsx) — a hint, never a block, since a genuinely short form is fine flat. - Auto-chunking as the safety net for when an admin doesn’t act on the hint:
EmbeddedFormFill’swithAutoChunkedPagesgroups a form’s fields into synthetic pages of 5 (AUTO_CHUNK_SIZE) purely for rendering, whenever a form has ≥6 fields (AUTO_CHUNK_FIELD_THRESHOLD) and the admin never set any explicitpageIndex— no data is written back, and the standalone/forms/public/:idfill page (PublicFormFillClient) is entirely unaffected, since it callsPaginatedFormFillFieldsdirectly with the real fields rather than through this function. An admin who does set explicitpageIndexvalues (real, deliberate groupings via the hint) is left alone completely — auto-chunking only ever fills a gap, never overrides a real choice. - A height cap as a backstop for the remaining edge case — an admin who explicitly paginates but still
dumps too many fields onto one page bypasses auto-chunking (any field with
pageIndex > 0counts as “already split”).PaginatedFormFillFieldsgained an opt-incapFieldsHeightprop that wraps just the current page’s fields (not the progress bar or Back/Next/Submit row, which stay outside the scroll region and always visible) in amax-h-[480px] overflow-y-autocontainer. Opt-in and Pages-only: the standalone fill page never passes it, so its behavior is unchanged; onlyEmbeddedFormFillpassescapFieldsHeight(always — harmless for a short page, since it never reaches 480px). - Pagination itself stays a Forms-builder concept either way, not something reimplemented inside Pages — auto-chunking is a rendering-only fallback, not a second pagination mechanism.
- Discoverability, unchanged from the first pass:
- Field input text is explicitly dark (
components/forms/form-fill-fields.tsx’sinputClass) — a real regression caught by a user typing into a live Bold-themed page: the sharedinputClass(used by every text/number/email/phone/date input,<textarea>, and<select>) never set its own text color, so it silently inherited the ambient page text color. On the standalone/forms/public/:idfill page that’s always been browser-default black against a light page, so it never looked broken — but the embedded card is always white/light regardless of the Page’s theme (seeRegistrationSection’s own comment), and a Bold-themed page setstext-[var(--bold-fg)](white, for a dark background) on its outer wrapper. That white color cascaded all the way into the input’s typed text, invisible against the input’s own light#F9F9F9background — labels and placeholders already had their own explicit gray, which is why only the typed value went missing. Fixed by adding an explicittext-[#121212]toinputClassitself, benefiting both the embed and (harmlessly, since it already rendered dark by default) the standalone fill page. - The multi-page progress bar now carries a “Step X of Y” label (
PaginatedFormFillFields’sProgressBar) — the bar was a bare, unlabeledh-0.5line; a real visitor mistook it for how much of the form they’d filled in (field-completion progress) rather than which page of the wizard they were on. Benefits every multi-page form everywhere the shared component renders, not just Pages. - That label’s count reacts to conditional visibility, not just structural
pageIndex— a second real report: a page whose only field is conditionally shown (e.g. a “which team?” follow-up that only appears once “Want to volunteer?” is answered “Yes”) left the label reading “Step 1 of 2” even when the current answers meant page 2 had nothing visible on it — while the Submit button (isLastPage, computed via the sameisFieldVisiblecheck) already correctly knew there was no reachable second page and showed itself instead of “Next”. The two disagreeing was the actual bug.visiblePageIndices(new) recomputes, on every render, which structural pages currently have ≥1 visible field given the livevalues;isSinglePageand the bar’scurrent/totalare both driven by this instead of the raw structuralpageCount, so the label now always agrees with what the button is about to do. Deliberately not the same kind of value aspageitself (the currently-mounted page index) — that one must stay fixed while the visitor is looking at it, precisely so an earlier answer changing mid-view can’t yank them off their current page (see this component’s own file comment); recomputing what the label displays carries none of that risk, since it never triggers navigation on its own. - A form’s own name/description can be hidden (
RegistrationContent.hideFormMeta, boolean, off by default) — a direct consequence of thetitle-rendering fix above: once a form’s own name became visible, an admin whoseREGISTRATIONsection already has its own heading/body (or whose form’s title/description was never meant to be visitor-facing, e.g. an internal name) needed a way to suppress it again.EmbeddedFormFilltakes ahideMetaprop;RegistrationSectionpassescontent.hideFormMetastraight through. Not validated server-side — a plain boolean UI flag, same “harmless if malformed” precedentHERO.hideOverlayTextalready sets. - A true two-column split layout (
style.layout: 'split', distinct fromalign) puts the heading/body in its own column beside the white form card, side by side — mirrorsABOUT.content.layout’s own stacked/split concept, but lives instylerather thancontentsince it’s a page-builder layout choice, not something the section’s meaning depends on. Falls back to stacked when there’s no heading/body to put in the text column, same “nothing to split against” guardABOUT’s own split uses for its image. Whenlayoutis'split',alignis repurposed:'left'puts the form card on the left (text right), anything else (including unset) puts it on the right (text left) — the same “unset defaults to the same sideABOUT’s ownimagePositiondefaults to” convention. discuva-admin’sSectionStyleControlsreflects this by swapping the Alignment control’s label and options to “Form Position” (Left/Right only, no Center) wheneverstyle.layout === 'split'is selected for that section. The two columns useitems-center, notitems-start— the form card’s height varies with field count/pagination and the text column’s with copy length, so the two are rarely equal; a real page with short copy next to a multi-field form showed a visibly empty gap under the text withitems-start, whichitems-centerredistributes evenly above and below instead. The text column also needsspace-y-3on itsFormattedTextwrapper (a real gap fixed alongside this) — without it, multiple blank-line-separated paragraphs inbodyrender with no visible space between them, since Tailwind’s preflight zeroes<p>margins by default.
sectionHeadingClass now scales up on desktop, not a flat text-2xl (discuva-member, shared by every
content.heading on Speakers/Schedule/Testimonials/FAQ/Merch/Countdown) — a real, measured report: it was the
one heading treatment in this whole file with no responsive scale-up at all (24px at every viewport), while every
other heading (HeroSection’s title, AboutSection’s splitHeadingClass, RegistrationSection’s own
splitHeadingClass) scales up via a md: breakpoint. Confirmed via getComputedStyle against a real page — FAQ
measured 24px next to a sibling split heading measuring 30px for what’s visually the same role. Now
text-2xl md:text-3xl, matching RegistrationSection’s split heading exactly; fixing the shared function fixes
all six section types at once, not just the one that got reported.
FAQ answer text is no longer two legibility cuts stacked on the question at once (discuva-member,
FaqAccordion) — the question is text-sm font-medium; the answer was text-xs font-light at 70% foreground
opacity — three separate reductions (size, weight, and opacity) compounding into text a user directly compared
unfavorably against a reference page, where question/answer stay much closer in size, differentiated mainly by
color. Now text-sm (same size as the question) with the default weight (no font-light) and --bold-fg-80/
text-gray-600 (up from -70/text-gray-500) — confirmed via getComputedStyle against a real page: answer
went from 12px/300-weight/70%-opacity to 14px/400-weight/80%-opacity, now matching the question’s 14px
exactly.
FAQ gained a size knob (the one addition, not a general heading/subheading/body scale system — considered
and deliberately declined; see below) — style.size, added to SECTION_STYLE_APPLICABILITY.FAQ alongside its
existing accentColor, scales the section heading and the question/answer text together, as one bounded
4-option choice, never as independent controls per element. sectionHeadingClass (discuva-member,
section-renderer.tsx) gained an optional second parameter — a size-class override defaulting to its existing
text-2xl md:text-3xl, so the other five callers (Speakers/Schedule/Testimonials/Merch/Countdown) are completely
unaffected by its existence. FAQ_HEADING_SIZE_CLASS/FAQ_TEXT_SIZE_CLASS (section-style.ts) hold the four
buckets; md is deliberately identical to the pre-existing defaults (text-2xl md:text-3xl heading, text-sm
question/answer), so an unset size renders byte-for-byte the same as before this knob existed. FaqAccordion
gained a textSizeClass prop (default "text-sm") applied to both the question <span> and the answer <p>,
keeping them the same size at every bucket — matching the same “question and answer stay close in size” reasoning
the legibility fix just above already established. Why not a general text-size system: raised directly by a
user after several rounds of “this text is too small” reports — every prior report turned out to be a real bug or
inconsistency (missing responsive scale-up, three compounding legibility cuts), not a case where a well-tuned
default was simply wrong for one church’s taste. Exposing independent heading/subheading/body size controls
across every section would trade “consistently coherent defaults, tuned together” for “combinatorially many ways
for a page to look mismatched” — a larger source of future complaints, not a smaller one. The existing size
knob (Stats/Speakers/Merch/Countdown, now FAQ) stays the deliberately narrow shape: one bounded, 4-option “how
prominent is this section’s main content” choice per section, extended only where a real reported need shows up,
never a raw pixel-scale system.
size extended to every section’s body/paragraph text — a direct follow-up request (“add this to every body
text in the pages setup”), applied to HERO’s subtitle, ABOUT’s body (both stacked and split), REGISTRATION’s body
(both stacked and split), and TESTIMONIALS’ quote, via SECTION_STYLE_APPLICABILITY.HERO/ABOUT/REGISTRATION/
TESTIMONIALS in discuva-admin each gaining a size entry (ABOUT’s is set unconditionally on the static table
entry rather than in getStyleApplicability’s dynamic split/stacked branch, since — unlike align — body text
exists in both layouts). This is not the declined general system: it’s the same one bounded size knob each
section already had a slot for, just exposed for more sections’ plain paragraph copy, not an independent
heading/subheading/body control added on top. FAQ_TEXT_SIZE_CLASS (discuva-member, section-style.ts) was
renamed to BODY_TEXT_SIZE_CLASS and its comment widened accordingly — sm/md/lg/xl map to text-xs/
text-sm/text-base/text-lg, shared by every one of these callers rather than a per-section table, since plain
body copy doesn’t need section-specific buckets the way Stats/Speakers/Merch/Countdown’s one prominent element
knobs do. Every call site follows the “only override when explicitly set” pattern (style?.size ? BODY_TEXT_SIZE_CLASS[style.size] : <original hardcoded default>) rather than forcing an unset size through
md, because About and Registration each already had two different pre-existing defaults (stacked vs. split)
that a single md bucket can’t simultaneously reproduce — leaving size unset must still render byte-for-byte
identical to every already-published page regardless of which layout it’s in.
Size control now explains what it resizes — with the knob live on 9 of 10 section types, “Small/Medium/Large/
X-Large” alone (no caption, unlike Spacing’s always-present “Room above and below this section…” line) left an
admin unable to tell what clicking it would actually change without trial and error. StyleApplicability.size
changed from a plain boolean to a string — the presence check (applicability.size &&) and the caption text
are now the same value, so there’s no separate lookup table that could drift out of sync with the applicability
table itself. Each section type gets its own one-line caption (e.g. Stats: “Size of the stat numbers.”, Speakers:
“Size of each speaker’s photo.”, Registration: “Size of the heading and body text beside the form.”, FAQ: “Size of
the heading and the question/answer text.”), rendered by SectionStyleControls directly under the Size buttons,
matching the Spacing control’s existing caption pattern exactly.
Validation is envelope-only at the DTO layer (PageSectionDto: id/type/content as a plain object) —
per-type structural validation happens in PageService.assertValidSections, a switch (section.type) checking
each type’s required fields, the same “jsonb content a decorator alone can’t cross-check” pattern
FormService.assertValidOptionMetadata/assertValidPostSubmitOutcomes already use, rather than a
class-transformer discriminated union (deliberately not introduced, to keep this module’s validation style
consistent with the rest of the codebase). REGISTRATION’s formId is the one genuinely cross-referential check
— it must reference a Form that actually exists in this tenant (formRepo.findOneBy).
Endpoints — two controllers, not three like Forms (a Page has no authenticated-member behavior; it’s purely public or admin-managed):
PageAdminController(AdminGuard+PAGES_READ/PAGES_WRITE): full CRUD (GET/POST /pages,GET/PATCH/DELETE /pages/:id),POST /pages/:id/images— one generic multipart upload endpoint reused by every image slot in every section type (hero background, each speaker photo, gallery), returning{url, publicId}only, never touching thePagerow itself (the client embeds the url into whichever section’s content it belongs to on the next save) — andPOST/DELETE /pages/:id/og-image(mirrorsFormService.setCoverImage/removeCoverImage’s “delete the previous Cloudinary asset only after the new one is safely saved” ordering). Admin-only image uploads mean the volume of an abandoned upload (started, page edit never saved) is low enough that — unlikeFormFieldAttachment’s visitor-facing uploads — no orphan-cleanup sweep is built for this; an accepted v1 tradeoff, not an oversight.PagePublicController(@Public(), no guard):GET /pages/public/:slug— published-only, returns the fullPageincluding every section verbatim. Unlike Forms’PublicFormDto, nothing is stripped — every section is content the church chose to show publicly, there’s no “spoiler” concern the way an unselectedDROPDOWNoption’soptionMetadatahas. Not rate-limited (read-only, unlike Forms’ public write endpoints). AlsoGET /pages/public/:slug/preview?token=...(draft content, gated bypreviewTokeninstead ofisPublished— see the draft/publish section above) andGET /pages/public(every published page for the resolved tenant,{slug, title, seoDescription, updatedAt}only — feeds discuva-member’ssitemap.xml/robots.txt/llms.txt, described just below).listPublishedfilters onisPublishedalone (not narrowed byslugthe waygetForPublicis), backed byAddPagesPublishedIndex1796626800000’s partial index (WHERE is_published = true) rather than a full-column one — thefalseside (most pages, most of the time) never needs to appear in it.
pages:read/pages:write are backfilled onto every existing tenant’s SuperAdmin role by
GrantPagesPermissions1796194800000 (same class of fix as GrantFormsPermissions/GrantSocialMediaPermissions
— a brand-new AdminPermission is only auto-granted to a SuperAdmin role at the moment that role row is
created, a one-time Object.values(AdminPermission) snapshot, so every tenant provisioned before this module
existed needs the new permission strings appended explicitly). CreatePagesTable1795849200000 shipped without
this grant migration, which is why the “Pages” sidebar entry (gated behind pages:read in discuva-admin’s
NAV_STRUCTURE) silently never appeared for any pre-existing tenant even though the module itself worked once
reached directly.
Early-access rollout, controlled by the Pages Rollout control (§Platform Admin — Tenant Management) — pages
is a toggleable module (KNOWN_MODULES) not included in any plan’s features by default (no @RequiresPlan/
PlanGuard on either controller, unlike Forms), same posture Social Media used before it went GA
(MakeSocialMediaOverrideOnly1793736000000’s own comment). ModuleEnabledGuard’s own plan-feature resolution
(PlanFeatureResolverService) already checks Tenant.moduleOverrides[moduleKey] ahead of plan membership either
direction — true grants access regardless of plan, false blocks it regardless of plan — so a platform admin
grants specific churches under test access from discuva-platform’s dedicated Pages page (GET/PUT /platform/pages/rollout, the same one-toggle-plus-multi-select mechanism Social Media Rollout uses, see below),
with every other tenant getting a 403 from isEnabled’s plan-membership fallback until the rollout is flipped to
“everyone.” PageAdminController.isPlatformEnabled
(GET /pages/platform-enabled) is a lightweight, side-effect-free access ping the discuva-admin frontend reads to
decide whether to render the real builder or a “Coming Soon” panel — reaching the handler at all already proves
access, since ModuleEnabledGuard 403s first otherwise. PagePublicController carries the same @RequiresModule
ModuleEnabledGuard(no separate plan check needed there either) — a tenant without access could never have created a page to view publicly anyway.PageAdminController’sGET /pages/:idis a wildcard route, soPagePublicControlleris registered first inPagesModule.controllers— otherwise it would swallowGET /pages/public/:slug, the same route-ordering issueFormsModulealready documents.
Visitor-submitted testimonials (CreateTestimonialSubmissionsTable1796799600000) — a TestimonialSubmission
entity (page FK ON DELETE CASCADE, sectionId — the section’s client-generated jsonb id, not a real FK since
sections aren’t DB rows — quote, nullable name, status defaulting to 'PENDING', indexed on
(page, status)) lets a visitor submit their own testimony on a TESTIMONIALS section that has
content.acceptSubmissions: true. PageService.submitTestimonial (public, unauthenticated) rejects outright
unless the referenced sectionId actually exists on that exact page and is a TESTIMONIALS section with
acceptSubmissions on — a stale or guessed sectionId can’t attach a submission to a section that never opted
in. Every submission lands PENDING; an admin approves or rejects it via listTestimonialSubmissions/
moderateTestimonialSubmission. Only APPROVED rows ever reach a visitor — getForPublic/getForPreview merge
them (mapped to { quote, name }, no photo — public submission never accepts an image upload) onto the relevant
section’s content.items server-side, on every request, so discuva-member’s rendering needs no second fetch:
it just sees a possibly-longer items array. The submit endpoint follows the app’s one established public-write
convention exactly — @Public() + @Throttle({ default: { limit: 5, ttl: 60_000 } }), the same pattern
FormPublicController’s submit route already uses; there is no CAPTCHA/honeypot convention anywhere in this
codebase, so none was introduced here either.
No custom-domain resolution, no auto-provisioned homepage. A page is reachable at
member.<church-subdomain>.<baseDomain>/p/<slug> today, resolved the same way discuva-member’s existing public
form-fill pages resolve tenant (subdomain read from the Host header server-side, or the X-Tenant-Subdomain
header client-side — see that app’s own tenant-resolution notes). There’s no isHomepage designation and no
“every tenant gets a default page” provisioning — a church’s first page can be a homepage or a conference page,
same feature either way; both are natural fast-follows once this is in active use, not built for v1.
SEO/LLM discoverability (discuva-member) — app/p/[slug]/page.tsx sets alternates.canonical and, for a
non-preview request, injects two JSON-LD <script type="application/ld+json"> blocks: a WebPage schema always,
and an FAQPage schema (mapping each FAQ section’s {question, answer} pairs to mainEntity) whenever the
page has one — a direct match for Google’s FAQ rich results and the kind of thing LLM answer engines cite
directly. No Event schema: a HERO section’s dateRangeText is free text (“March 5–7, 2026”), not a real
date, so there’s no reliable startDate to populate — that would need the section’s content model to gain a
real structured date field first. Three new tenant-aware routes, all reading the request’s Host header the
same way app/manifest.ts already does (none of them can be statically generated at build time for that
reason): app/sitemap.ts (/sitemap.xml, one entry per published page via GET /pages/public), app/robots.ts
(/robots.txt, allow: /p/, disallow: / — everything else is an authenticated member-only screen with
nothing for a crawler), and app/llms.txt/route.ts (/llms.txt, an emerging, not-yet-formally-standardized
convention some LLM crawlers/agents read the way traditional crawlers read robots.txt — a markdown summary of
the tenant’s name and its published pages).
A preview link is explicitly noindex, nofollow, never just “not linked from anywhere.” robots.ts’s
allow: /p/ rule can’t tell a preview URL (?previewToken=...) apart from the live one at the same path — it
only ever sees the URL’s path, not its query string — so that file alone doesn’t keep a leaked preview link out of
a search index. generateMetadata closes that gap directly: an unpublished/draft request (resolved.isPreview)
now returns { robots: { index: false, follow: false } } instead of falling through to an empty {} (which would
silently inherit the default indexable behavior — Next only emits a noindex meta tag when a route’s own
generateMetadata says so). The rest of preview mode’s existing “no OG/canonical/JSON-LD” posture is unchanged;
this is strictly an addition, not a behavior change to the live path. A nonexistent slug doesn’t need the same
treatment — notFound() already produces a real 404, which no crawler indexes regardless of any meta tag.
| Method | Route | Auth | Notes |
|---|---|---|---|
| GET | /pages/platform-enabled |
AdminGuard (PAGES_READ) | { enabled: true } always — the “Coming Soon” gate; reaching this handler at all already proves access (see above) |
| POST | /pages |
AdminGuard (PAGES_WRITE) | Create a page with its sections in one call |
| GET | /pages |
AdminGuard (PAGES_READ) | List all pages — unpaginated, same policy as Forms. Each row also carries churchCalendarEntitled: boolean (tenant-wide, same value on every row) — the builder opens a page for editing straight from this list, not a separate per-id fetch, so the “requires upgrade” badge on a CHURCH_CALENDAR section needs it here |
| GET | /pages/:id |
AdminGuard (PAGES_READ) | Get one page with sections, plus the same churchCalendarEntitled: boolean GET /pages carries |
| PATCH | /pages/:id |
AdminGuard (PAGES_WRITE) | Update page. title/seoDescription/theme/accentColor/backgroundColor/fontFamily/showHeader/headerLogoUrl/headerLinks/sections write to draft* only (arrays = replace wholesale, no per-item id to diff against; each section may carry an optional style: {align?, columns?, size?, accentColor?}, an optional hidden: boolean, and an optional navLabel: string); slug/isPublished still write live immediately |
| DELETE | /pages/:id |
AdminGuard (PAGES_WRITE) | Delete a page |
| POST | /pages/:id/publish |
AdminGuard (PAGES_WRITE) | Copies every draft* field onto its live counterpart and sets isPublished = true |
| POST | /pages/:id/duplicate |
AdminGuard (PAGES_WRITE) | Body { slug, title? }. Copies the source page’s current draft into a brand-new, unpublished page under the given slug — see PageService.duplicate’s own comment |
| POST | /pages/:id/images |
AdminGuard (PAGES_WRITE) | Multipart, field name file, max size MAX_PAGE_IMAGE_UPLOAD_MB. Generic upload for any section’s image slot — returns { url, publicId } only, doesn’t touch the page row |
| POST | /pages/:id/og-image |
AdminGuard (PAGES_WRITE) | Multipart, field name file. Sets Page.draftOgImageUrl |
| DELETE | /pages/:id/og-image |
AdminGuard (PAGES_WRITE) | Clears the draft OG image |
| GET | /pages/:id/testimonial-submissions |
AdminGuard (PAGES_READ) | Optional ?status=PENDING|APPROVED|REJECTED. Moderation queue for a TESTIMONIALS section with acceptSubmissions on |
| PATCH | /pages/:id/testimonial-submissions/:submissionId |
AdminGuard (PAGES_WRITE) | Body { status: 'APPROVED' | 'REJECTED' } |
| GET | /pages/public |
Public | Every published page for the resolved tenant — {slug, title, seoDescription, updatedAt} only |
| GET | /pages/public/:slug |
Public, 404 unless isPublished |
Returns the full PublicPageDto (theme/accentColor/backgroundColor/fontFamily) — every section verbatim including its optional style, except any section with hidden: true (dropped from the array entirely) |
| GET | /pages/public/:slug/preview |
Public, ?token= must match previewToken |
Same PublicPageDto shape (hidden sections filtered the same way), sourced from draft* — no isPublished check |
| POST | /pages/public/:slug/testimonials |
Public, rate-limited (5/min) | Body { sectionId, quote, name? }. 202, no content. Lands PENDING — rejected outright unless sectionId is a TESTIMONIALS section on this page with acceptSubmissions on |
discuva-admin UX: same client-side search/filter and disclosure pattern as Forms, for the same reasons
(GET /pages is likewise a plain unpaginated find(), likewise admin-authored reference data). app/pages/page.tsx’s
list gained a search box (title/slug) plus a Published/Draft status filter. app/pages/sections-editor.tsx
already collapsed each section by default (collapsedIds, pre-existing) — but SectionStyleControls
(Layout/Alignment/Columns/Size/Accent Color/Spacing, up to six sub-controls depending on the section type) was
always rendered in full the moment a section was expanded, reproducing the same long-scroll problem one level
deeper. Now collapsed behind its own “Style” toggle, same dot-indicator-when-already-configured treatment as
Forms’ field editor.
Church Calendar (src/church-calendar/)
Admin-configurable, dated programme calendars — the in-app equivalent of the flyer a church already designs each month for social media (“Programs in the month of September, themed REMEMBERED — Special Thanksgiving on the 6th, Holy Communion on the 9th, …”). A ChurchCalendar has a title, an optional theme, a startDate/endDate range (a single month, a full year, or anything in between — not a fixed month field), an optional accentColor (hex, drives the admin-side exported flyer’s gradient bands — there’s no tenant-wide brand-color setting to fall back to, so the flyer template falls back to a built-in default when unset), an isPublished flag, and an ordered entries: ChurchCalendarEntry[] ({ id, date, time?, title, description?, imageUrl? }, plain jsonb, whole-array replace on save — same convention Page.sections/Form.postSubmitOutcomes already use, since there’s no per-entry DB row to diff against). id is client-generated. time is an optional 24-hour HH:mm string (@Matches on the DTO) — an all-day or time-TBD entry simply omits it; adding it required no migration since entries is jsonb. The admin builder flags (non-blocking — never rejected server-side) two entries sharing the same date and time as a likely scheduling conflict, since two things legitimately happening at once (e.g. Kids Church and the Main Service) isn’t necessarily a mistake.
Validation (ChurchCalendarService, mirrors PageService.assertValidSections’s “structural checks the decorator layer can’t express” pattern): endDate cannot be before startDate; every entry’s own date must fall inside [startDate, endDate], and every entry needs a non-empty title. Entries are sorted by date before persisting (on both create and any update that replaces entries) so the admin list and the member view always render in date order regardless of the order entries arrived in the request.
Two controllers, deliberately on different base paths to avoid a route-ordering hazard — PagesModule’s own public/admin controllers share one base path and depend on registration order in the module’s controllers array to keep the wildcard :id route from swallowing the more specific one; ChurchCalendarMemberController sidesteps that entirely by mounting at church-calendar/member instead of sharing church-calendar with the admin controller’s :id wildcard.
ChurchCalendarAdminController(AdminGuard+CHURCH_CALENDAR_READ/WRITE,@RequiresModule('church_calendar'), and@RequiresPlan(PlanFeature.CHURCH_CALENDAR)+PlanGuard— unlike Pages, Church Calendar is a normal Pro-plan feature, not override-only early access): full CRUD plusPOST /church-calendar/:id/images, a generic multipart upload reused by every entry’s photo slot (mirrorsPageAdminController’s image endpoint exactly —{ url, publicId }only, doesn’t touch the calendar row; the caller embeds the url into whichever entry it belongs to on the next save). Same “no orphan-cleanup sweep for an abandoned upload” accepted tradeoff as Pages’ section images, for the same reason (admin-only, low volume).ChurchCalendarMemberController(JwtAuthGuard, member+worker, same@RequiresModule/@RequiresPlan/PlanGuardgating):GET /church-calendar/member/current— published calendars whoseendDatehasn’t passed yet, ordered bystartDateascending (so a shorter “this month” calendar and a longer-running “this year” one can both surface together). “Today” is computed via a newDateService.today()method (see below) rather than a barenew Date(), so a calendar doesn’t disappear a few hours early/late for a church whose timezone differs from the server’s.
Plan/permission plumbing — the same three pieces Pages needed, done proactively this time instead of as a follow-up fix:
AdminPermission.CHURCH_CALENDAR_READ/CHURCH_CALENDAR_WRITE, a newChurch Calendarpermission group.KNOWN_MODULESkeychurch_calendar.PlanFeature.CHURCH_CALENDAR, added to all four Pro plan variants’featuresbyAddChurchCalendarToProPlans1793908800000—AddFormsToProPlan(the precedent) only targeted the bareprorow, leavingpro-annual/pro-usd/pro-usd-annualwithoutforms; this one covers all four so the feature isn’t inconsistently available depending on which Pro variant a tenant happens to be subscribed to.GrantChurchCalendarPermissions1796367600000backfillschurch_calendar:read/writeonto every existing tenant’sSuperAdminrole. This is the exact fixGrantPagesPermissionshad to ship as a follow-up after the Pages sidebar entry silently never appeared for any pre-existing tenant — done as part of the same PR here instead of after the fact.
Events flow into calendars (include_events, default on). A calendar is a view of the scheduled events in its date range plus manual “other dates” (themes, fasting periods, holidays), so services aren’t entered twice. ChurchCalendarService.withItems loads events by startTime (a day either side, then filtered on the church-local date via ChurchTimezoneService), and util/calendar-items.ts merges them with the manual entries: a manual entry with the same date and title as an event is folded into it (its photo/description kept); repeat_display SUMMARY (default) collapses a repeating service (same recurringEventId, 2+ dates in range) into one line with a label read from the dates (“Every Sunday”, “Every other Wednesday”, “Daily”, else “N dates”), EACH lists every date; hidden_event_keys (event:<id> / series:<recurringEventId>) leaves chosen events off. Responses carry the merged list as items — admin GET/POST/PATCH /church-calendar[/:id] (all audiences, plus eventOptions for the editor’s show/hide list), GET /church-calendar/member/current (only events meant for the caller, via eventVisibleToViewerSql) and Pages’ CHURCH_CALENDAR sections (EVERYONE events only). Migration AddCalendarEventOptions. accent_color is now the calendar’s colour theme (presets + custom) used by the flyer and the member app, with text colour chosen for contrast. Admin “Make it an event” on a manual date opens /events?new=1&name=&date=&time= pre-filled. A calendar may now be saved with no manual entries when it includes events.
New CloudinaryFolder member 'church-calendar-images' and new PlatformSettingKey.MAX_CHURCH_CALENDAR_IMAGE_UPLOAD_MB (default 5MB, same shape as MAX_PAGE_IMAGE_UPLOAD_MB).
The member app’s list-page header is its own KNOWN_ASSETS entry (church-calendar-hero, src/tenant/constants/known-assets.constant.ts), overridable from discuva-admin’s Mobile App Appearance page like every other page header. It was initially built reusing the existing events-hero key to save a step — fixed once flagged, since that would have meant a church customizing the Events page’s header silently changed Church Calendar’s too (or vice versa). Each page header gets its own key unless it’s one of the few deliberately shared ones (e.g. prayer-hands-bible across prayer/prayer-requests/evangelism) — reusing one should be a conscious choice, not a shortcut.
DateService.today() (new): today’s date as a plain 'yyyy-MM-dd' string in the church’s configured timezone, for comparing against a date-typed column. Added because nothing on DateService already did this — format(new Date(), 'yyyy-MM-dd') renders using the server process’s timezone, which can land on the wrong calendar day near midnight for a church whose timezone differs from the server’s; today() runs new Date() through the same toZonedTime shift startOfDay()/endOfDay() already use before formatting, so the date components come out right regardless of what timezone the Node process itself is running in.
| Method | Route | Auth | Notes |
|---|---|---|---|
| POST | /church-calendar |
AdminGuard (CHURCH_CALENDAR_WRITE) | Create a calendar with its entries in one call |
| GET | /church-calendar |
AdminGuard (CHURCH_CALENDAR_READ) | List all calendars — unpaginated, same policy as Pages/Forms |
| GET | /church-calendar/:id |
AdminGuard (CHURCH_CALENDAR_READ) | Get one calendar with entries, merged items and eventOptions (events in range, with hidden) |
| PATCH | /church-calendar/:id |
AdminGuard (CHURCH_CALENDAR_WRITE) | Update calendar. entries omitted = untouched, an array = replace wholesale; also includeEvents, repeatDisplay (SUMMARY/EACH), hiddenEventKeys |
| DELETE | /church-calendar/:id |
AdminGuard (CHURCH_CALENDAR_WRITE) | Delete a calendar |
| POST | /church-calendar/:id/images |
AdminGuard (CHURCH_CALENDAR_WRITE) | Multipart, field name file, max size MAX_CHURCH_CALENDAR_IMAGE_UPLOAD_MB. Generic upload for any entry’s photo slot — returns { url, publicId } only |
| GET | /church-calendar/member/current |
JwtAuthGuard (member+worker) | Published calendars with endDate >= today (church-timezone-aware), ordered startDate ascending; each with merged items (only events meant for the caller) |
Department Goals (src/department-goal/)
A per-department, per-review-cycle goal-setting and blind two-sided rating workflow: each cycle, every
department’s Head of Department (HOD) writes goals for their team during a short opening window; once that
window closes, goals lock and the department works toward them; at cycle end, both the church (via
discuva-admin) and the HOD (via discuva-member) rate each goal 1–5 with a reason, independently, and neither
sees the other’s score until both are in — then both reveal together to the HOD, Deputy-HOD, and department.
A DepartmentGoalCycle is global/church-wide — one shared opening window covers every department at once, not
a cycle per department.
Entities:
DepartmentGoalCycle(department_goal_cycles) —name,startDate/graceDeadline/endDate(plaindatecolumns,'yyyy-MM-dd', compared viaDateService.today()— same convention asPledgeCampaign/ChurchCalendar),isActive(church can deactivate/cancel a cycle early),approvalChain(nullablejsonb, an ordered array of up to 3{level, adminId}entries — same jsonb-array-of-plain-object patternForm.postSubmitOutcomesuses;null/empty is the default and means this cycle has no approval gate at all).DepartmentGoal(department_goals) —cycle(M:1,CASCADE— a goal doesn’t outlive its cycle),department(M:1,RESTRICT, notCASCADE— this is a compliance record; department deletion is blocked by existing goal history rather than silently erasing it, the same postureDepartmentService.deletealready takes when workers are still assigned),title(displayed as “KPI” — Key Performance Indicator — in both admin/member UIs; kept namedtitleinternally, no migration needed for a display-only relabel),description(nullable, displayed as “KPI Description”; previously informally doubled as “the measurable target” in discuva-member’s placeholder copy — that role now belongs totimelineToAchieve),timelineToAchieve(nullable text, displayed as “Timeline to Achieve Target” — freeform, e.g. “Q3 2026” or “by end of cycle”, never validated as a date or enforced, deliberately distinct from the cycle-levelstartDate/graceDeadline/endDatewhich do carry real enforcement),churchRating/selfRating(nullable smallint, 1–5),churchRatingReason/selfRatingReason(nullable text),churchRatedAt/selfRatedAt(nullable timestamptz),churchRatedByAdmin/selfRatedByMember(nullable FK,SET NULL). A goal’stitle/description/timelineToAchievebecome immutable (service-layer check, not a DB constraint) the instant either rating is set.DepartmentGoalApproval(department_goal_approvals) — one row per(cycle, department),@Unique(['cycle', 'department']), created lazily the moment that department’s HOD writes their first goal in a chain-configured cycle (never eagerly backfilled across every department).currentLevel(smallint, default 1 — which of the cycle’sapprovalChainlevels is currently active),status(PENDING|CHANGES_REQUESTED|COMPLETE, defaultPENDING),completedAt(nullable timestamptz, set when the last level approves).currentLevelis never decremented — aREQUEST_CHANGESdecision keeps the same level active so the same approver re-reviews the HOD’s revision, rather than restarting the chain from level 1.DepartmentGoalComment(department_goal_comments) — mirrorsFollowUpNote’s shape.cycle(M:1,CASCADE),department(M:1,RESTRICT),postedByAdmin(nullable FK,SET NULL),content(text),approvalLevel(nullable smallint — set only when this row is a formal approve/reject decision echoed into the feed) anddecision('APPROVED'|'CHANGES_REQUESTED'|null—nullfor a general, non-decision comment). One table backs two distinct use cases: a formal per-level decision (always tied to a level) and a free-standing comment anyDEPARTMENT_GOALS_WRITEadmin can post regardless of whether a chain is configured — the HOD sees both in one chronological feed.
Indexes on department_goals: a composite (cycle_id, department_id), not two standalone single-column
indexes — getCurrentForMember (the hottest read path, hit on every load of a member’s or HOD’s goals page)
filters on both together, and the composite still fully serves the cycle_id-only queries (getGoalsForCycle,
getReport) via the leftmost-prefix rule, so a separate cycle_id index would only add write overhead for no
read benefit. A standalone department_id index is kept alongside it (not covered by the composite, since
department_id isn’t the leading column) so the RESTRICT FK on departments.id doesn’t force a full table
scan of department_goals every time an admin attempts to delete a department. church_rated_by_admin_id/
self_rated_by_member_id are deliberately left unindexed — neither Admin nor Member rows are ever
hard-deleted anywhere in this codebase (member deletion isn’t exposed at all, per this doc’s own policy; admins
are deactivated via isActive, never deleted), so the SET NULL cascade those FKs exist for can never actually
fire, and an index that backs a cascade check that never runs is pure overhead. department_goal_cycles itself
carries no index beyond its primary key — bounded, admin-created reference data (a handful of cycles per tenant
per year) stays small enough for the whole table’s lifetime that Postgres’s planner would prefer a sequential
scan over an index scan regardless, the same reasoning departments/venues already go unindexed on their own
non-PK columns.
Stage is computed, never stored (DepartmentGoalService.getEffectiveStage) — every guard and query goes
through this one function rather than comparing graceDeadline/endDate directly at the call site, since a
real instance of that exact bug (an isActive-style flag ANDed with a date check inconsistently across call
sites) already exists elsewhere in this codebase, in pledge.service.ts:
INACTIVE — cycle.isActive === false (blocks every write path, including both ratings — a
deactivated/cancelled cycle can't still be rated after the fact)
OPENING — today < graceDeadline (HOD writes/edits/removes goals)
IN_PROGRESS — graceDeadline <= today < endDate (church can correct a goal, audit-logged; no one else writes)
REVIEWED — today >= endDate (both ratings can be submitted, write-once each)
INACTIVE is deliberately distinct from REVIEWED, not a fifth date-derived bucket folded into it.
Authorization — department-scoped, not “is a lead somewhere.” DepartmentService gained two new methods
(assertIsDepartmentLead(memberId, departmentId, leadType?), getLeadRoles(memberId)) rather than reusing
the pre-existing getDepartmentIdForLead, which resolves “the” department for a member via an arbitrary
findOne and silently assumes a member leads at most one department — untrue, since DepartmentLead has no
uniqueness constraint on workerProfile, only on (department, leadType). Every HOD-write endpoint calls
assertIsDepartmentLead(memberId, departmentId, DepartmentLeadTypeEnum.HOD) scoped to the department in the
URL, not inferred.
Visibility rule for GET /department-goals/member/current — resolves every department relevant to the
caller (any department they lead, via DepartmentLead, plus their WorkerProfile.department/
secondaryDepartment) and returns one entry per department. A department the caller leads resolves via
DepartmentLead, not WorkerProfile — a Deputy-HOD’s own primary department can differ from the department
they actually lead, and the response must reflect the led department, not their profile’s. Per role: HOD/
Deputy-HOD see the goal list live from day one, including mid-draft during OPENING; a plain department
member sees nothing for that department until the cycle has locked (IN_PROGRESS or later) — goals are
withheld (goals: null), never partially shown. Every goal’s churchRating/selfRating (and their reasons)
are nulled out server-side unless both are non-null, for every role including the HOD who just submitted
one side — “neither sees the other’s score until both are in” is enforced uniformly, not by role; the HOD’s
own just-submitted rating is confirmed to them via the submit response itself, not by this endpoint reflecting
it back early.
cycle.hasApprovalChain (!!cycle.approvalChain?.length) — added after discuva-member’s IN_PROGRESS
banner was found to overpromise. getEffectiveStage flips IN_PROGRESS → REVIEWED purely on
today >= endDate, but submitSelfRating requires both REVIEWED and assertApprovalComplete (the
department’s approval chain, if one is configured, must be COMPLETE) — so a date-only “review begins
tomorrow” message can be wrong for a chain-configured cycle whose approval hasn’t finished. The member
frontend now checks this flag to soften that banner’s wording (see app/department-goals in that repo)
instead of promising a fixed date whenever a chain might still be pending.
Two controllers, same route-ordering rationale as Church Calendar’s admin/member split — mounted at
distinct base paths so the admin controller’s :id wildcard can’t swallow the member controller’s routes:
DepartmentGoalAdminController(department-goals,AdminGuard+DEPARTMENT_GOALS_READ/WRITE,@RequiresModule('department_goals')— deliberately not stacked withPlanGuard/@RequiresPlan, unlike Church Calendar’s own admin controller; see the plan-gating note below): cycle CRUD, the cross-department goal list, a goal correction endpoint (IN_PROGRESSonly, always audit-logged asDEPARTMENT_GOAL_CORRECTED), the church-rating endpoint, and the per-department report.DepartmentGoalMemberController(department-goals/member,JwtAuthGuard, same@RequiresModule): thecurrentread endpoint plus HOD-only goal CRUD, self-rating, goal-history (audit log filtered to that goal, scoped to the goal’s own department’s lead — “the HOD can see that history… not discover secondhand”), and the PDF export, all nested undercycles/:cycleId/departments/:departmentId/...so the department a write targets is always explicit in the URL rather than inferred from “the member’s one department.”
Plan gating — one deliberate deviation from the Church Calendar precedent. Church Calendar stacks
ModuleEnabledGuard and PlanGuard together. PlanGuard checks features.includes(required) only and
never consults Tenant.moduleOverrides, while ModuleEnabledGuard does — so a platform-admin comp override
for a non-Pro tenant would still 403 through PlanGuard alone. Since Department Goals has no numeric usage
cap to justify PlanGuard’s extra check (no @CountsTowardLimit use case), both controllers here use
ModuleEnabledGuard alone — plan membership is still resolved through it, just without the second guard’s
override-blind failure mode.
AdminPermission.DEPARTMENT_GOALS_READ/WRITE, a newDepartment Goalspermission group.KNOWN_MODULESkeydepartment_goals.PlanFeature.DEPARTMENT_GOALS, added to all four Pro plan variants’featuresbyAddDepartmentGoalsToProPlans1794081600000(same all-four-variants shape asAddChurchCalendarToProPlans).GrantDepartmentGoalsPermissions1797231600000backfillsdepartment_goals:read/writeonto every existing tenant’sSuperAdminrole, in the same migration wave as the table creation — not a later follow-up fix, the mistakeGrantPagesPermissionshad to correct after ship.
PDF export (PdfService.generateDepartmentGoalReport/drawDepartmentGoalReport) — one more draw* method
alongside the session/event/giving-statement reports already there, a single goals table (Goal / Self Rating /
Church Rating) for the HOD’s own department at the same visibility rules getCurrentForMember already applies
(a rating column reads — for exactly the same reason it would be null in the member API — not yet both
submitted — so the export can never leak a one-sided score either).
Frontend surfaces. discuva-admin gets a new top-level Department Goals nav entry (under People, next to
Departments) with three screens: a cycle list + create panel (app/department-goals/, same list+panel shape as
Games/Departments), a cycle detail page (per-department goal breakdown, the correction affordance in
IN_PROGRESS, the church-rating form in REVIEWED, and a “move grace deadline”/deactivate control), and a
report page reusing components/charts/bar-chart.tsx exactly as the attendance leaderboard does. discuva-member
gets a single new screen (components/layout/department-goals.tsx, linked as a new card from the existing
/department-summary page) showing every department relevant to the caller at their role’s visibility: a live
editable goal list for the HOD during OPENING (add/edit/remove, reusing the LeaveCard-style
editable-vs-read-only split), a locked read-only list during IN_PROGRESS, and the per-goal reveal plus a
self-rating form once REVIEWED. The PDF download button ports discuva-admin’s existing blob-download pattern
(URL.createObjectURL + <a download>) into discuva-member for the first time — that pattern didn’t exist
there before this.
discuva-member polish pass, done directly against the first version of this screen: the delete-goal
confirmation used window.confirm() in the initial cut — replaced with the app’s shared ConfirmModal
(components/ui/confirm-modal.tsx), the same component the Games/front-desk leave-guard work already
established as the one confirmation pattern this app uses. The self-rating input was a <select> — replaced
with a row of five tappable number buttons (thumb-friendly touch targets, no native picker chrome). Every
mutation (add/edit/remove a goal, submit a rating) previously refetched GET .../member/current through the
same isLoading flag the page’s initial load uses, which re-collapsed the whole page back to its loading
skeleton after every small action — fixed by gating the skeleton on isLoading && !data instead of isLoading
alone, so a background refetch just swaps in fresh data over what’s already on screen. Added a days-remaining
line under the stage badge during OPENING (“Goal-writing closes in N days”) and IN_PROGRESS (“review begins
in N days”) — the cycle is inherently time-boxed and the UI gave no sense of how much of the window was left.
Header redesigned to match Department Summary — reported as feeling “disconnected.” The screen originally
opened with a plain flat header (small back arrow, text eyebrow, title), modeled after front-desk-session.tsx
— the wrong precedent: that screen is a live-operating console, deliberately minimal since you’re mid-task, not
a browse/manage destination screen like this one. Landing here immediately after Department Summary’s full-bleed
hero (the only way into this screen — via the card there) read as leaving the department-management flow
entirely. Now uses the same hero treatment as department-summary.tsx (h-[40vh] image, dark overlay, overlaid
back button and title) — and deliberately reuses Department Summary’s own image (teamwork-hands-unity) rather
than a new asset, since the shared image is what actually reads as “still the same flow,” not merely “also has a
hero.”
Same fix applied to games-history.tsx — an app-wide audit for this exact pattern (a browse/destination
screen, one tap from an already-hero’d screen, itself flat) found one other real instance: Game History, reached
from Games’ own join screen (games-join.tsx, hero key game-backdrop). Now shares that same image, for the
same reason. Everything else without a hero survived the audit as legitimately flat by an already-consistent
convention, not an oversight — detail pages drilled into from an already-hero’d list (announcement-detail.tsx,
event-detail.tsx, sermon-detail.tsx) and live/operational “in the moment” screens (front-desk-session.tsx,
game-session.tsx, my-live-assignment.tsx, small-group-attendance.tsx) don’t get one, deliberately — a
detail view’s own content is the point, not a repeated generic photo, and an operational screen mid-task needs
its vertical space for what’s actually live, not a static image.
order-of-service.tsx’s hero was missing its title in the empty state. An earlier fix here correctly removed
a “This Week” placeholder title that read as an answer (“here’s this week’s service”) even when nothing was
actually scheduled, contradicting the “No service scheduled” message rendered just below it — but the fix
removed the <h1> entirely rather than giving it a neutral fallback, leaving the hero with only its small
eyebrow line and nothing else whenever programme has no name, thinner than every other hero’d page’s
consistent eyebrow-plus-title pair. Fixed by falling back to the eyebrow’s own text (“Order of Service”) as the
title instead of hiding it — asserts nothing about whether a service is scheduled, but still fills the slot.
discuva-admin: the New Cycle button gave no reason for staying disabled on an invalid date order. Reported
live: entering a graceDeadline before startDate (or an endDate before graceDeadline) left “Open Cycle”
permanently greyed out with zero explanation — nothing distinguished “you haven’t finished the form” from “what
you entered is contradictory.” Fixed with the same blockReason pattern church-calendar/page.tsx’s own
date-range form already established: a specific message (“The opening window can’t close before it starts…”)
rendered as an amber inline hint above the button, replacing the bare boolean valid check. Also added min
attributes to the Opening Window Closes / Cycle Ends date inputs (min={draft.startDate} /
min={draft.graceDeadline}) so the browser’s own date picker discourages the invalid combination before the
admin even finishes picking, mirroring the minDate/maxDate props Church Calendar’s date-range picker already
uses for the same purpose.
discuva-member: two real gaps found and fixed in the More grid. First, an inconsistency — Leave Request and
Evangelism are worker-gated tiles (components/layout/profile.tsx’s ministryTiles, only rendered when
isWorker) exactly like Prayer Roster, but only Prayer Roster carried the badge: "Workers" label communicating
that restriction; the other two now do too. Second, and more substantial: plain department members had no way
to reach Department Goals at all. The only entry point was the card on Department Summary — itself one of the
leadershipTiles, gated isHod (which, per AuthService.getProfile, is actually true for both HOD and
Deputy-HOD, since it’s departmentLeadRepo.exists({ workerProfile }) with no leadType filter — so Deputy-HODs
already had a path). A regular department member has neither role, so despite the backend (getCurrentForMember)
and the page itself both already handling a plain-member “read-only, revealed at the right stage” view correctly,
there was no door into it. Fixed by adding “Department Goals” as its own tile directly in ministryTiles (open
to every worker, badge: "Workers", moduleKey: "department_goals") — the HOD-only write tools (Dept.
Attendance/Summary) stay exactly where they were, under Leadership.
help.tsx’s Department Goals FAQ category was still isHod-only, gating it behind a role that no longer
matches who can actually reach the feature — broadened to isWorker, badge changed to “Workers” to match the
tile, and a new Q&A (“I’m not the HOD — what do I see?”) added specifically for the plain-member read-only
experience, alongside the existing HOD-facing questions (same “one category, mixed relevance per question”
shape the pre-existing Evangelism category already uses).
discuva-admin’s own ConfirmModal (components/ui/confirm-modal.tsx) is now applied everywhere, not just the
five screens it already covered — the Department Goals work above surfaced that this app had a working shared
confirm-dialog component that most of its own destructive actions still bypassed in favor of window.confirm().
Swept and converted every remaining instance: Games (delete question, end session — both the list page and the
detail page), Pages (unpublish, delete), Sermons (delete), Church Calendar (delete), and Forms (delete). Each
conversion follows the same shape already established by facility-rental/service-programme/etc.: a
confirm* state (boolean or holding the pending record) gates the modal’s render, the original handler drops
its window.confirm() guard and becomes the onConfirm callback, and the button that used to call the handler
directly now just opens the confirm state. window.confirm() no longer appears anywhere in discuva-admin.
app/department-goals/layout.tsx — every feature directory in discuva-admin needs its own layout.tsx
wrapping <Shell> (the sidebar, topbar, and help button); the three page files were initially added without one,
so the route rendered with no chrome at all. Fixed by copying the exact one-line pattern app/games/layout.tsx/
app/departments/layout.tsx already use — Next.js layouts apply to everything nested under them, so this single
file covers all three Department Goals routes.
Help/FAQ coverage. discuva-admin’s contextual help (components/layout/help-system.tsx, the ? button’s
per-page tips) gained a /department-goals entry alongside Departments/Games/Church Calendar, plus a one-word
addition to the People section’s welcome-tour blurb. discuva-member’s Help page
(components/layout/help.tsx) gained a new “Department Goals” FAQ category, gated visible: isHod && isModuleEnabled("department_goals") — deliberately its own category rather than folded into the existing
“Department Leadership” one, since Dept. Summary/Finance Requests/Pastor Feedback aren’t module-gated at all and
folding a Pro-plan-gated feature’s FAQ into an always-visible category would show HODs on non-Pro tenants
questions about a feature they can’t reach.
Optional hierarchical approval chain + comments (per-cycle, opt-in). A church can configure an ordered chain
of up to 3 admin approvers on a cycle (approvalChain); a department’s goals then must pass through every level,
in order, before they can be rated. This is layered on top of the stage machine above, never a replacement for
it — see DepartmentGoalApprovalService:
- Blocking is per-department, not per-cycle.
getEffectiveStagestays a pure function of dates, shared by every department, completely unchanged. A chain-gated department’s goals additionally can’t be rated (submitChurchRating/submitSelfRatingboth still requireREVIEWEDfirst, unchanged) until that specific department’sDepartmentGoalApproval.statusisCOMPLETE— a slow approver on one department never freezes any other department or the cycle’s own calendar. - The HOD keeps write access past
OPENINGfor any department whose approval isn’t yetCOMPLETE— this is what makes “reopen editing after changes are requested” work even after the grace deadline has passed.createGoal/updateGoalAsHod/deleteGoalAsHodeach call the new privateassertGoalWritable, which branches on whethercycle.approvalChainis configured; for a cycle with no chain, behavior is byte-for-byte the sameOPENING-only gate as before. - Decisions (
DepartmentGoalApprovalService.decide) — only the admin assigned to the department’scurrentLevelmay act (403 otherwise), and that admin may not be the department’s own registered HOD (403, mirrors Finance Request’s self-approval block).REQUEST_CHANGESrequires a non-blank comment, setsstatus = CHANGES_REQUESTEDwithout advancing the level, and writes a decision-echoDepartmentGoalComment.APPROVEadvancescurrentLevel(or setsstatus = COMPLETEat the last level) and also echoes a comment. Once the HOD saves any edit whileCHANGES_REQUESTED, status silently flips back toPENDINGat the same level (onHodGoalWrite) — the same approver re-reviews the revision. - Chain mutability — once any department under a cycle has a recorded decision, the chain’s structure
(which level numbers exist) is locked (
assertChainMutable); reassigning which admin holds an existing level stays allowed at any time, so a deactivated/departed approver doesn’t permanently stall a department. - General comments are independent of the chain — any
DEPARTMENT_GOALS_WRITEadmin can post one on any department at any time (even with no chain configured at all), as an alternative to the existingcorrectGoaldirect-edit flow. - Push notification on every decision and comment —
DepartmentGoalApprovalServicefiresNotificationDispatchService.notifyMember(push-only, no email leg yet) to the department’s HOD and Deputy-HOD after everydecide()call and everyaddComment()call, gated by the newEmailCategory. DEPARTMENT_GOAL_ACTIVITYcategory toggle (same per-tenant on/off mechanism every other notification type in this codebase already uses — a church can disable it from the existing category-settings UI). - Admin-facing correction history —
getGoalHistoryForAdminis a new, parallel method next to the existing HOD-onlygetGoalHistory(not a relaxation of it — zero behavior change to the member-facing path), exposed atGET /department-goals/cycles/:id/goals/:goalId/history. - No new
AdminPermissionwas introduced — the picker/decide/comment endpoints reuse the existingDEPARTMENT_GOALS_READ/WRITE.GET /department-goals/cycles/admin-optionsis deliberately scoped toDEPARTMENT_GOALS_WRITErather than reusing theADMIN_READ-gated admin-user-list endpoint, so an admin who can manage goal cycles isn’t also required to hold admin-management access just to pick an approver.
Bulk import (DepartmentGoalImportService/DepartmentGoalImportController, src/department-goal/). Lets an
admin upload goals on behalf of a department’s HOD — e.g. the admin emails the HOD a downloaded template, the HOD
fills it offline, and the admin uploads the completed file — rather than requiring the HOD to type each goal into
discuva-member. Mirrors MemberImportService’s two-phase preview → commit shape (LimitedFileInterceptor,
ExcelService.buildWorkbook, ExcelJS parsing) rather than introducing a new pattern:
- Scoped per cycle and per department (
.../cycles/:cycleId/departments/:departmentId/bulk-import/...) — one upload always targets exactly one department’s goals for one cycle, so the template has no Department column, only the same three fields the member-app goal form itself captures:KPI(title, required),KPI Description(description),Timeline to Achieve Target(timelineToAchieve). - Reuses
DepartmentGoalService.assertGoalWritable(now public) at both preview time and commit time — the exact same writability rule a HOD’s owncreateGoalcall is gated by (cycle must beOPENING, or, for a chain-configured cycle, any stage before that department’s approval reachesCOMPLETE). This is what stops an admin from bulk-writing goals into a cycle that’s already closed for that department, and the commit-time recheck catches a cycle that closed in the gap between preview and commit. - Each row is validated with the existing
CreateGoalDto(class-validator) — a preview response includes every row (valid or not) with itserrors: string[], so the admin sees exactly what’s wrong before committing; commit only creates goals for rows with zero errors and reports the rest back asfailedRows. - Persisted as two new tables,
department_goal_import_jobs/department_goal_import_rows(job holds cycle/department/status/counts/createdBy; each row holds its raw parseddataasjsonbpluserrors, decoupled fromDepartmentGoal’s own columns so schema drift doesn’t break historical import rows — same reasoning asMemberImportRow). - Every goal created this way gets
DepartmentGoal.createdByAdminset (a new nullable FK,SET NULLon delete) — purely an audit marker. The goal behaves identically to one the HOD entered directly: samePENDING-by-default approval flow, and the HOD can still edit or delete it from discuva-member for as long asassertGoalWritablesays the department’s goals remain open (i.e. right up to the cycle’s grace deadline, or later still if an approval chain is configured and not yetCOMPLETE). - A job can only be committed once (
BadRequestExceptionon a second commit attempt) — there’s no update-in-place; re-uploading a corrected file starts a new job. GoalView(the shape returned byGET /department-goals/member/current) now includescreatedByAdmin: booleanso discuva-member can flag admin-uploaded goals for the HOD’s attention rather than presenting them identically to self-written ones.
| Method | Route | Auth | Notes |
|---|---|---|---|
| POST | /department-goals/cycles |
AdminGuard (DEPARTMENT_GOALS_WRITE) | Create a cycle |
| GET | /department-goals/cycles |
AdminGuard (DEPARTMENT_GOALS_READ) | List all cycles — unpaginated |
| PATCH | /department-goals/cycles/:id |
AdminGuard (DEPARTMENT_GOALS_WRITE) | Edit dates (incl. moving graceDeadline anytime — re-validated), toggle isActive, set/reassign approvalChain |
| GET | /department-goals/cycles/admin-options |
AdminGuard (DEPARTMENT_GOALS_WRITE) | Active admins as {id, name, email}[], to populate an approval-chain picker |
| GET | /department-goals/cycles/:id/goals |
AdminGuard (DEPARTMENT_GOALS_READ) | Cross-department goal list for the cycle |
| PATCH | /department-goals/cycles/:id/goals/:goalId |
AdminGuard (DEPARTMENT_GOALS_WRITE) | Church correction — IN_PROGRESS only, audit-logged |
| POST | /department-goals/cycles/:id/goals/:goalId/church-rating |
AdminGuard (DEPARTMENT_GOALS_WRITE) | Write-once, REVIEWED only, and (if a chain is configured) only once that department’s approval is COMPLETE |
| GET | /department-goals/cycles/:id/goals/:goalId/history |
AdminGuard (DEPARTMENT_GOALS_READ) | Admin-facing correction history — same audit data as the HOD-only member route, no department-lead gate |
| GET | /department-goals/cycles/:id/report |
AdminGuard (DEPARTMENT_GOALS_READ) | Per-department avg self/church score + the gap |
| GET | /department-goals/cycles/:id/approvals |
AdminGuard (DEPARTMENT_GOALS_READ) | Bulk per-department approval status for the cycle |
| GET | /department-goals/cycles/:cycleId/departments/:departmentId/bulk-import/template |
AdminGuard (DEPARTMENT_GOALS_WRITE) | Downloads an .xlsx template (KPI, KPI Description, Timeline to Achieve Target columns) — see Bulk Import below |
| POST | /department-goals/cycles/:cycleId/departments/:departmentId/bulk-import/preview |
AdminGuard (DEPARTMENT_GOALS_WRITE) | Multipart file upload — parses + validates each row, returns a job + per-row errors without writing any goals yet |
| GET | /department-goals/cycles/:cycleId/departments/:departmentId/bulk-import/:jobId |
AdminGuard (DEPARTMENT_GOALS_WRITE) | Re-fetch a previewed job and its rows |
| POST | /department-goals/cycles/:cycleId/departments/:departmentId/bulk-import/:jobId/commit |
AdminGuard (DEPARTMENT_GOALS_WRITE) | Creates a DepartmentGoal for every error-free row; returns {createdCount, failedRows} |
| POST | /department-goals/cycles/:id/departments/:departmentId/approval-decisions |
AdminGuard (DEPARTMENT_GOALS_WRITE) | {decision: 'APPROVE'|'REQUEST_CHANGES', comment?} — only the department’s current-level approver may call this |
| GET | /department-goals/cycles/:id/departments/:departmentId/comments |
AdminGuard (DEPARTMENT_GOALS_READ) | Full comment + decision feed for the department, real admin names |
| POST | /department-goals/cycles/:id/departments/:departmentId/comments |
AdminGuard (DEPARTMENT_GOALS_WRITE) | {content} — general comment, always available regardless of chain config |
| GET | /department-goals/member/current |
JwtAuthGuard (member+worker) | Every department relevant to the caller, at their role’s visibility |
| POST | /department-goals/member/cycles/:cycleId/departments/:departmentId/goals |
JwtAuthGuard (HOD only) | OPENING only |
| PATCH | /department-goals/member/cycles/:cycleId/departments/:departmentId/goals/:goalId |
JwtAuthGuard (HOD only) | OPENING only, frozen once rated |
| DELETE | /department-goals/member/cycles/:cycleId/departments/:departmentId/goals/:goalId |
JwtAuthGuard (HOD only) | OPENING only, frozen once rated |
| POST | /department-goals/member/cycles/:cycleId/departments/:departmentId/goals/:goalId/self-rating |
JwtAuthGuard (HOD only) | Write-once, REVIEWED only |
| GET | /department-goals/member/cycles/:cycleId/departments/:departmentId/goals/:goalId/history |
JwtAuthGuard (dept. lead only) | Audit log, filtered to DEPARTMENT_GOAL_CORRECTED for this goal |
| GET | /department-goals/member/cycles/:cycleId/departments/:departmentId/approval |
JwtAuthGuard (dept. lead only) | This department’s approval status — null if no chain configured or no goal written yet |
| GET | /department-goals/member/cycles/:cycleId/departments/:departmentId/comments |
JwtAuthGuard (dept. lead only) | Read-only comment + decision feed (no member-facing POST — comments stay admin-authored) |
| GET | /department-goals/member/cycles/:cycleId/departments/:departmentId/pdf |
JwtAuthGuard (any member of the department) | application/pdf download — HOD, Deputy-HOD, or a plain worker/member (primary or secondary department), not HOD-only; exporting what’s already shown on-screen isn’t a write action |
Social Media Module (src/social-media/)
Central, tenant-scoped connector framework for cross-posting to a church’s social accounts from one compose box.
All the shared OAuth/media/scheduling infrastructure is real and fully wired — platform-level app credentials,
per-tenant encrypted token storage, the connect/callback flow, real multi-file upload, per-placement validation,
retention, and scheduled publishing. Facebook, Instagram, and YouTube have real publishers
(FacebookGraphPublisher/InstagramGraphPublisher, backed by the Meta Graph API; YouTubePublisher, backed by
the YouTube Data API v3 — see below for both); X and TIKTOK still resolve to NotConnectedPublisher (or
PlatformDisabledPublisher if a platform-admin has switched it off) via SocialPublisherRegistry, which always
fails honestly rather than pretending to succeed. X’s API dropped its free tier entirely in February 2026
(pay-per-use, ~$0.015–$0.20 per post) — wiring it in is a pricing decision (who absorbs that per-post cost:
Discuva or the church?) as much as an engineering one, not scheduled yet. TIKTOK’s Content Posting API restricts
any unaudited app to SELF_ONLY (private) visibility until TikTok completes its own audit, so there’s nothing
meaningful to test until that’s done — also not scheduled yet. Wiring in either is the same shape either way — see
the publisher extension point below; SocialPostService and every controller stay unchanged when that lands.
Entities:
SocialAccount(social_accounts) —platform(SocialPlatform:FACEBOOK/INSTAGRAM/X/YOUTUBE/TIKTOK),displayName,externalAccountId(nullable — Page/Channel/user id, resolved during the OAuth exchange),isConnected,connectedAt/connectedBy. Also carries the OAuth token itself, allselect: falseso a normalfind()never returns them:accessTokenEncrypted,refreshTokenEncrypted(nullable — not every platform issues one),tokenExpiresAt,scope. Encrypted viaEncryptionService(AES-256-GCM), same convention asTenantCommunicationProviderConfig.credentialsEncrypted.SocialPost(social_posts) —content,status(SocialPostStatus:DRAFT→SCHEDULED/PUBLISHING→PUBLISHED/PARTIALLY_PUBLISHED/FAILED),createdBy(nullable FK →admins,SET NULL),publishedAt,scheduledFor(nullable — set only whileSCHEDULED). No longer hasimageUrl; seeSocialPostMedia.SocialPostTarget(social_post_targets) — one row per(post, account, placement), so a single post’s per-platform and per-placement outcome is tracked independently:status(SocialPostTargetStatus:PENDING/SUCCESS/FAILED),placement(SocialPlacement:FEED/STORY/REEL— Instagram Stories/Reels and YouTube Shorts are genuinely different publish surfaces from a feed post, not just a platform distinction; one connected account can have multiple targets across placements for the same post),errorMessage,publishedAt,externalPostId(nullable — the platform’s own id for the published post/video, set from a successfulPublishResult; what a stats fetch or any future “open this on the platform” link looks up). Also carries the composer’s per-target customization:contentOverride(nullable text —nullmeans this target still sharesSocialPost.content) andmediaFocalX/mediaFocalY(nullable numeric, 0-1 — a click-to-crop-focus point, only meaningful forSTORY/REEL; bothnullmeans “let Cloudinary’sg_autocontent-aware cropping choose,” not “no crop” — seeSocialMediaCropServicebelow).SocialPostMedia(social_post_media) — real Cloudinary-backed attachments, replacing the old free-textimageUrl.url,publicId(needed to delete the actual asset, not just the row),mimeType,sizeBytes,width/height/durationSeconds(nullable, used bySocialMediaValidationService),order.SocialPlatformApp(social_platform_apps, public schema, control-plane) — one row perSocialPlatformholding Discuva’s own OAuth app credentials (clientId,clientSecretEncrypted,redirectUri,scopes). Unlike email/SMS BYOK, a tenant cannot register their own Meta/Google/X developer app, so this is platform-owned, not per-tenant.isActiveis the platform-admin kill switch — see below.
OAuth connect + callback flow:
GET /social-media/accounts/:id/authorize-url(tenant-authenticated,AdminGuard) —SocialOAuthConnectServicelooks up the account’s platform, confirms itsSocialPlatformAppis registered and active, encodes{accountId, tenantId, nonce, issuedAt}into astatetoken viaOAuthStateService(AES-256-GCM encrypt — the auth tag makes it tamper-evident, doubling as OAuth’s CSRF protection without a separate HMAC/JWT; 10-minute expiry), and returns the platform’s authorize URL for the frontend to redirect to.GET /v1/integrations/social/:platform/oauth/callback—@Public(), added toTenantMiddleware’s exclude list (src/tenant/tenant.module.ts— do not remove this without also removing the exclude, the documented failure mode is a silent 404 in production, previously hit for the YouTube WebSub callback). Called directly by Meta/Google/X’s redirect, which carries no tenant subdomain —stateis decoded to recovertenantId/accountId, the tenant’sschemaNameis looked up, and the rest runs insiderunInTenantContext(...)(same pattern as the giving-checkout webhook): exchange the code for tokens, encrypt and store them on the matchingSocialAccount, setisConnected/connectedAt, then redirect the browser back to discuva-admin (ADMIN_LOGIN_URL+/social-media?connected=<platform>or?error=<reason>). Never throws past the top level — the caller is a browser mid-redirect, not an API client — failures are logged server-side and surfaced to the browser as a generic?error=connection-failed.
The publisher extension point (publisher/social-platform-publisher.interface.ts): SocialPlatformPublisher
is a one-method interface (publish(account, post, placement): Promise<{success, error?, externalPostId?}>),
resolved per SocialPlatform by SocialPublisherRegistry. placement is the specific SocialPostTarget’s
placement (FEED/STORY/REEL) — a single account can have multiple targets across placements for the same
post, so this is the only way a publisher knows which one a given call is for; a publisher that doesn’t support a
placement SocialMediaValidationService allows should fail that call explicitly via PublishResult.error, not
silently substitute FEED. On every resolve() call the registry also checks
PlatformSocialAppService.isPlatformDisabled(platform) — if a platform-admin has switched a platform off, it
returns PlatformDisabledPublisher (distinct wording from NotConnectedPublisher: “temporarily disabled by
Discuva,” not “this church hasn’t set this up”) instead of whatever publisher is registered, without touching any
tenant’s already-stored tokens. Wiring in a real platform means implementing this interface plus a matching
SocialTokenRefresher (token/, for transparent access-token renewal) and SocialOAuthExchanger (oauth/, for
the authorize-URL/code-exchange mechanics) — SocialPostService, the controllers, and SocialTokenResolverService
never change.
Meta (Facebook/Instagram) implementation (platform/meta/meta-graph-api.service.ts) — MetaGraphApiService
holds the Graph API mechanics shared by both FacebookOAuthExchanger/InstagramOAuthExchanger and
FacebookGraphPublisher/InstagramGraphPublisher, since Instagram Business publishing runs on the same Meta App,
the same Business Login OAuth dialog, and the same Page access token as Facebook — only which node you call (a
Page vs. its linked IG Business Account) differs:
resolvePageAccessToken— code → short-lived user token → long-lived user token (fb_exchange_token) →GET /me/accountsfor the Page(s) granted. Exactly one Page is the expected/supported outcome (a church connects one Page); zero or multiple both throw a clear, actionable error rather than guessing which one to use. The Page token returned this way doesn’t expire in practice and Meta issues norefresh_tokenfor it —FacebookOAuthExchangerandInstagramOAuthExchangerboth omitexpiresInSeconds/refreshTokenfrom theirOAuthExchangeResult, sotokenExpiresAtstaysnullandSocialTokenResolverServicenever attempts a refresh (both platforms stay registered toNoRefresherAvailable— nothing to implement there).getInstagramBusinessAccountId— one extra call (GET /{pageId}?fields=instagram_business_account) is all that separatesInstagramOAuthExchangerfromFacebookOAuthExchanger;externalAccountIdends up being the IG Business Account id instead of the Page id, but the stored token is the same Page access token either way.publishToFacebookPage(pageId, pageAccessToken, content, media, placement)— onlyFEEDis implemented; any other placement throws immediately, before any request is made (SocialMediaValidationService’s constraints table only definesFEEDfor Facebook today, so this isn’t reachable yet, but the rejection is real, not assumed). FEED: text-only →/feed; single image →/photos; single video →/videos(checked in that preference order if a post somehow carries both). No multi-image gallery support yet — matches what validation actually checks today (primaryVideo, not a gallery).publishToInstagram(igUserId, pageAccessToken, content, media, placement)— always two calls: create a media container (/media), then/media_publish. What differs byplacementis the container’smedia_type:STORY→'STORIES'(image or video);FEED/REELare, as far as this API is concerned, the same call — a video posted via the Content Publishing API always processes as a Reel (media_type: 'REELS') even when it also appears in the normal feed, and an image needs nomedia_typeat all (defaults toIMAGE) in either placement. Video containers process asynchronously on Meta’s side, so a bounded poll (status_codeviaGET /{containerId}, 3s interval, 20 attempts) waits forFINISHEDbefore publishing rather than racing Meta’s own processing. Stories don’t visibly render thecaptionfield, but it’s passed through anyway rather than silently dropping content the admin typed.
Every failure path in both publishers resolves to {success: false, error} — never throws. This matters because
SocialPostService.publish() calls publisher.publish() with no try/catch; a thrown error there would abort
every remaining target’s publish attempt, not just the one platform that failed, breaking the documented “one
platform failing never blocks the others” guarantee.
Not live-tested against a real Meta account from this environment (no outbound network access to
graph.facebook.com in the sandbox this was built in) — written correctly against Meta’s documented Graph API
contract and covered by unit tests mocking fetch, but the first real connect→publish run against an actual
connected Page/Instagram account is the real end-to-end verification.
YouTube implementation (platform/youtube/youtube-api.service.ts) — YouTubeApiService holds the Google
OAuth2 + YouTube Data API v3 mechanics for YouTubeOAuthExchanger, YouTubePublisher, and (unlike Meta)
YouTubeTokenRefresher:
buildAuthorizeUrlsetsaccess_type=offlineandprompt=consent— without both, Google only issues arefresh_tokenon a user’s very first-ever consent for the app; a later reconnect after revoking access would silently come back with norefresh_tokenat all otherwise, and there’d be nothing forSocialTokenResolverServiceto renew against once the short-livedaccess_tokenexpires.resolveChannelmirrorsresolvePageAccessToken’s “exactly one expected” pattern — a Google account can have multiple channels/brand accounts, same as a Facebook user managing multiple Pages; zero or multiple both throw a clear, actionable error.- No URL-passthrough upload. Unlike Meta’s Graph API (
file_url/image_url, Meta fetches the asset itself), the YouTube Data API has no such option —publishVideodownloads the attachment from its Cloudinary URL into memory, then streams those bytes to Google via the resumable upload protocol (POST .../videos?uploadType=resumableto start a session and get aLocationheader, thenPUTthe raw bytes to that URL). Loading the full file into memory rather than piping the download directly into the upload is a real, deliberate simplification — correct and fine at the scale a church’s social posts run at (the existing 200MB attachment cap), but worth knowing about if that cap ever grows. REELgets"#Shorts"appended to the description — the documented, best-effort signal for YouTube’s Shorts shelf, not a guaranteed classification; YouTube’s own aspect-ratio/duration heuristics still decide.- Google access tokens genuinely expire (~1 hour), unlike a Meta Page token —
YouTubeTokenRefresheris the first real (non-NoRefresherAvailable)SocialTokenRefresherimplementation.SocialTokenRefresher.refresh()only receives the bare refresh token, not the platform app/clientSecreta Google refresh request needs, so it looks its ownSocialPlatformApprow up directly viaPlatformSocialAppServicerather than requiring an interface change every other platform would have to accommodate too — it’s already irreducibly YouTube-specific by being this class at all. contentmaps onto YouTube’s separatetitle/descriptionfields (Discuva’s data model has only one caption field) astitle = content.slice(0, 100)(YouTube’s title cap),description = content(+ "#Shorts"forREEL).
Also not live-tested from this environment, for the same no-outbound-network reason as Meta — written against
Google’s documented OAuth2 and YouTube Data API v3 contracts, covered by unit tests mocking fetch.
SocialTokenResolverService — every publisher calls getValidAccessToken(accountId) instead of touching
SocialAccount’s encrypted columns directly. Takes an id, not an entity, since the token columns are select: false and a SocialAccount loaded via a normal relation (e.g. post.targets[].account) never carries them.
Decrypts and returns the token if not expired (60s safety margin); if expired and a refresh token exists, resolves
that platform’s SocialTokenRefresher (YouTubeTokenRefresher for YOUTUBE; NoRefresherAvailable, which throws,
for every platform with no real refresh flow — Meta Page tokens included, since they don’t expire) and persists the
renewed token.
Media validation (SocialMediaValidationService) — keyed on (platform, placement), not platform alone,
informed by researched per-platform specs (image/video size & duration caps, caption length, max image count).
validate(media, targets) takes content per target entry (not one shared param) — a target with its own
contentOverride validates against that override, not SocialPost.content, so two targets in the same call can
have entirely different caption lengths. Two-tier model: errors (wrong content type for the placement, over a
hard size/duration/caption limit) block that specific target before it ever reaches its publisher; warnings
(e.g. an Instagram Reel over the ~3-minute “ideal” length) surface without blocking. Enforced inside
SocialPostService.publish(), not just a frontend nicety — a target with unresolved errors is marked FAILED
with the validation message, and its publisher is never called. getConstraints() returns the same table as
JSON — GET /social-media/constraints exposes it so the composer can show a live per-target character counter
against the exact numbers enforced at publish time, without a round-trip per keystroke.
Per-target customization — override and crop. Most targets share SocialPost.content and its media
untouched; two independent, opt-in per-target adjustments exist for when a platform’s constraints don’t fit the
shared version:
contentOverride(PATCH /social-media/posts/:id/targets/:targetId/override, body{contentOverride: string | null},DRAFTposts only) — a target-specific caption, e.g. a shortened version for X’s 280-char limit while Facebook/Instagram keep the full text.nullexplicitly clears it, reverting to the shared content. Can also be set at creation time viaCreateSocialPostDto.targets[].contentOverride.- Crop focal point (
PATCH /social-media/posts/:id/targets/:targetId/focal-point, body{x, y: number | null},DRAFTposts only, both set or cleared together) — only meaningful forSTORY/REEL.SocialMediaCropServicecrops to9:16(the one universal, strict requirement both placements share across every platform that supports them —FEEDis deliberately never cropped, since no platform enforces a single “correct” feed aspect ratio the way Stories/Reels do) using Cloudinary’sg_autocontent-aware/saliency cropping (core product, no add-on) by default, org_xy_centerat the storedx/yif a focal point is set.x/yare normalized (0-1) floats — Cloudinary accepts gravity offsets as float percentages directly, so a click position on the composer’s rendered preview maps straight through with no pixel-dimension math on either side. The transformation is inserted into the existing Cloudinary delivery URL as a path segment (.../upload/c_fill,ar_9:16,g_auto/...) — no re-upload, no second stored asset per placement.
SocialPostService.publish() resolves both — target.contentOverride ?? post.content and
SocialMediaCropService.resolveMediaForPlacement(post.media, target.placement, focalPoint) — exactly once per
target, before validation and before calling that target’s publisher. A publisher never sees SocialPost/
SocialPostTarget directly, only the already-resolved content: string and media: SocialPostMedia[] (see
SocialPlatformPublisher’s own comment) — so a publisher can’t forget to apply an override or a crop, and
resolution logic lives in exactly one place regardless of how many platforms get wired in later.
Scheduled publishing — POST /social-media/posts/:id/schedule ({scheduledFor: ISO string}) sets status = SCHEDULED and adds a delayed job to the social-post-publish Bull queue (jobId = the post’s own id, both to
prevent double-scheduling and so cancelSchedule can find it again without a separate stored column). When the
delay elapses, SocialPostPublishProcessor enters the job’s tenant context (runInTenantContext, envelope carried
via buildJobEnvelope) and calls the exact same SocialPostService.publish() “Publish Now” calls — scheduling
only decides when that call happens, there is no second publish path. POST /social-media/posts/:id/schedule/cancel removes the pending job and reverts the post to DRAFT.
Draft media retention (SocialMediaRetentionScheduler) — daily sweep (@Cron('0 3 * * *')) across every
active tenant: any DRAFT-status post whose updatedAt is older than a configurable window
(PlatformSettingKey.SOCIAL_MEDIA_DRAFT_RETENTION_DAYS, default 30, platform-admin adjustable via the existing
PlatformSettingsService) has its SocialPostMedia rows and their Cloudinary assets deleted. SCHEDULED and
published posts are never touched — only abandoned drafts age out. Closes a gap the researched incumbents
(Buffer/Hootsuite/Later) don’t document clearly.
Publish semantics (SocialPostService.publish): every target is attempted independently — one platform (or
validation) failing never blocks the others. The post’s overall status is derived from how many targets actually
succeeded: FAILED if none did, PUBLISHED if all did, PARTIALLY_PUBLISHED otherwise. publishedAt on the post
is set whenever at least one target succeeded. A successful PublishResult.externalPostId (the platform’s own id
for the post/video) is persisted onto the target as externalPostId — a failed republish attempt leaves a prior
externalPostId untouched rather than clearing it.
Stats extension point (stats/social-stats-fetcher.interface.ts) — SocialStatsFetcher is a one-method
interface (getStats(account, externalPostId): Promise<PostStats>), resolved per SocialPlatform by
SocialStatsFetcherRegistry, same shape as the publisher/exchanger/refresher extension points.
GET /social-media/posts/:id/targets/:targetId/stats (SocialPostService.getTargetStats) resolves a target’s
platform fetcher and returns whatever it reports; throws BadRequestException if the target has no
externalPostId yet (never published). YouTubeStatsFetcher is the only real implementation today — it
calls YouTubeApiService.getVideoStats (videos.list?part=statistics), returning viewCount/likeCount/
commentCount only (dislikeCount has been private since December 2021; favoriteCount is permanently 0). This
is deliberately the Data API v3’s own statistics, not the separate YouTube Analytics API
(youtubeAnalytics/v2) — that’s a genuinely different product (its own scope yt-analytics.readonly, its own
base URL, enabled separately in Google Cloud Console) needed for anything richer: watch time, audience retention,
traffic sources. Not wired in — a deliberate, discussed scope decision, not an oversight. Facebook/Instagram stats
would reuse the same Graph API MetaGraphApiService already talks to (different permissions —
pages_read_engagement, instagram_manage_insights — not a separate product the way YouTube’s Analytics API is),
but no FacebookStatsFetcher/InstagramStatsFetcher exists yet; both platforms resolve to NoStatsAvailable
(throws) via the registry, same honest-failure posture as NotConnectedPublisher.
Deleting a post is only allowed while DRAFT or fully FAILED — a PUBLISHED/PARTIALLY_PUBLISHED/
PUBLISHING post’s target history is kept, not deletable, since it’s the record of what was actually attempted.
| Method | Route | Auth | Notes |
|---|---|---|---|
| POST | /social-media/accounts |
AdminGuard (SOCIAL_MEDIA_WRITE) | Register an account to post to; isConnected is always false on create — connecting is a separate step |
| GET | /social-media/accounts |
AdminGuard (SOCIAL_MEDIA_READ) | List all registered accounts |
| DELETE | /social-media/accounts/:id |
AdminGuard (SOCIAL_MEDIA_WRITE) | Remove an account |
| GET | /social-media/accounts/:id/authorize-url |
AdminGuard (SOCIAL_MEDIA_WRITE) | Returns {url} — the platform’s OAuth authorize URL, state-encoded to this account/tenant |
| GET | /v1/integrations/social/:platform/oauth/callback |
@Public(), tenant-excluded |
Called by the OAuth provider’s redirect, not the frontend directly — see above |
| GET | /social-media/constraints |
AdminGuard (SOCIAL_MEDIA_READ) | The (platform, placement) constraints table as JSON — for the composer’s live per-target counters |
| GET | /social-media/platform-enabled |
AdminGuard (SOCIAL_MEDIA_READ) | {enabled: boolean} — the platform-wide composer readiness gate; see below and “Platform Settings” |
| GET | /social-media/available-platforms |
AdminGuard (SOCIAL_MEDIA_READ) | {platforms: SocialPlatform[]} — which platforms the “Add Account” picker should offer; see below |
| POST | /social-media/posts |
AdminGuard (SOCIAL_MEDIA_WRITE) | {content, targets: {accountId, placement, contentOverride?}[]} — creates a DRAFT with one PENDING target per (account, placement) pair |
| GET | /social-media/posts |
AdminGuard (SOCIAL_MEDIA_READ) | Paginated (?page=&limit=) |
| GET | /social-media/posts/:id |
AdminGuard (SOCIAL_MEDIA_READ) | One post with its targets, each target’s account, and its media |
| POST | /social-media/posts/:id/media |
AdminGuard (SOCIAL_MEDIA_WRITE) | Multipart, field files (up to 10, 200MB cap, image/video only) — DRAFT posts only |
| DELETE | /social-media/posts/:id/media/:mediaId |
AdminGuard (SOCIAL_MEDIA_WRITE) | DRAFT posts only |
| PATCH | /social-media/posts/:id/targets/:targetId/override |
AdminGuard (SOCIAL_MEDIA_WRITE) | {contentOverride: string | null} — DRAFT posts only, null clears it |
| PATCH | /social-media/posts/:id/targets/:targetId/focal-point |
AdminGuard (SOCIAL_MEDIA_WRITE) | {x, y: number | null}, 0-1 — DRAFT posts only, must be set/cleared together |
| POST | /social-media/posts/:id/publish |
AdminGuard (SOCIAL_MEDIA_WRITE) | Attempts every target; see publish semantics above |
| GET | /social-media/posts/:id/targets/:targetId/stats |
AdminGuard (SOCIAL_MEDIA_READ) | {viewCount?, likeCount?, commentCount?} — YouTube only today; 400 if the target hasn’t published yet |
| POST | /social-media/posts/:id/schedule |
AdminGuard (SOCIAL_MEDIA_WRITE) | {scheduledFor: ISO string} — DRAFT only, must be in the future |
| POST | /social-media/posts/:id/schedule/cancel |
AdminGuard (SOCIAL_MEDIA_WRITE) | Reverts to DRAFT, removes the queued job |
| DELETE | /social-media/posts/:id |
AdminGuard (SOCIAL_MEDIA_WRITE) | DRAFT/FAILED only |
social_media is a toggleable module (KNOWN_MODULES, ModuleEnabledGuard). New social_media:read/
social_media:write permissions, backfilled onto existing SuperAdmin roles by
GrantSocialMediaPermissions1791504000000 (same class of fix as GrantFormsPermissions — a brand-new permission
is only auto-granted to a SuperAdmin role at the moment that role is created).
GET /social-media/platform-enabled — a guard-access ping, not a standalone switch. Originally paired with a
global PlatformSettingKey.SOCIAL_MEDIA_ENABLED kill switch (an all-tenants-at-once readiness gate, separate from
a tenant’s own module toggle) — retired once Tenant.moduleOverrides (see “Per-tenant manual override” above)
shipped, since plan-features-exclusion plus a per-tenant override achieves the same “not ready for everyone yet”
rollout control with actual per-church granularity, and with real backend enforcement (the old global switch only
ever gated this one frontend check, never the API itself — a technically-inclined tenant could always reach the
real endpoints regardless of what it was set to). The route stays: ModuleEnabledGuard above it already 403s
before the handler runs if the tenant’s own toggle is off, their plan doesn’t include social_media, and no
override grants it — so simply reaching the handler at all already proves access, and it unconditionally returns
{enabled: true}. discuva-admin’s /social-media page still fetches this on load and shows “Coming Soon” on
any failure (including the 403 this guard produces), so the frontend behavior is unchanged even though what’s
being checked underneath is now the real per-tenant module/plan/override chain instead of a separate global flag.
Per-platform availability. The module/plan/override chain above is all-or-nothing across every platform at
once — it can’t hide just X/TikTok while shipping Facebook/Instagram/YouTube. GET /social-media/available-platforms fills that gap: it intersects IMPLEMENTED_PLATFORMS
(src/social-media/constant/implemented-platforms.constant.ts — platforms with a real
SocialOAuthExchanger/SocialPlatformPublisher, currently Facebook/Instagram/YouTube; X/TikTok resolve to
NoExchangerAvailable/NotConnectedPublisher regardless of what’s registered for them) with
PlatformSocialAppService.listActivePlatforms() (registered and isActive in social_platform_apps).
discuva-admin’s AccountsPanel filters its “Add Account” platform <select> down to this list — a platform stays
unselectable until it’s both built and deliberately activated from discuva-platform’s existing Deactivate/Reactivate
toggle, with no new admin UI needed for it. This is a frontend picker restriction only — POST /social-media/accounts itself still accepts any SocialPlatform enum value; connecting an account for an
unavailable platform still fails honestly via NoExchangerAvailable either way, same as before this endpoint
existed.
Platform-admin surface (/platform/social-media-apps, discuva-platform): separate from the tenant-side module
toggle above — Discuva staff register each platform’s OAuth app credentials here (one app per platform, not
per-tenant) and flip the kill switch. New PlatformAdminPermission.SOCIAL_MEDIA_APPS_READ/WRITE (a distinct,
disjoint enum from tenant-side AdminPermission.SOCIAL_MEDIA_READ/WRITE — a tenant admin composing/publishing
posts never needs to see Discuva’s own app secrets).
| Method | Route | Auth | Notes |
|---|---|---|---|
| GET | /platform/social-media-apps |
PlatformAdminGuard (SOCIAL_MEDIA_APPS_READ) | Never returns clientSecretEncrypted — select: false |
| GET | /platform/social-media-apps/scope-catalog |
PlatformAdminGuard (no extra permission — metadata, same posture as permissions/groups) |
{[platform]: {scopes: {value,label,required}[], separator}} — drives the register form’s scope picker |
| POST | /platform/social-media-apps |
PlatformAdminGuard (SOCIAL_MEDIA_APPS_WRITE) | {platform, clientId, clientSecret, redirectUri, scopes: string[]} — upserts (one row per platform) |
| PATCH | /platform/social-media-apps/:platform |
PlatformAdminGuard (SOCIAL_MEDIA_APPS_WRITE) | {isActive} — the kill switch; never touches already-connected tenants’ SocialAccount tokens either way |
| DELETE | /platform/social-media-apps/:platform |
PlatformAdminGuard (SOCIAL_MEDIA_APPS_WRITE) | Hard delete — 204, 404 if not registered. Safe: SocialAccount has no FK to this table (just a platform enum string), so nothing orphans; an already-connected tenant’s tokens are untouched, same as deactivating |
Scope validation. scopes used to be a raw free-text string, passed straight into the OAuth scope query
parameter with no validation beyond “not empty” — a typo or wrong separator saved silently and only surfaced later,
either as Meta quietly dropping unrecognized permissions (no error at all, just fewer permissions than intended) or
Google’s consent screen showing invalid_scope. It’s now scopes: string[], one OAuth permission per entry, checked
in PlatformSocialAppService.upsertApp() against KNOWN_SOCIAL_SCOPES
(src/platform-admin/constant/known-social-scopes.constant.ts) — a per-platform whitelist with a required flag per
scope, verified against Meta’s and Google’s own permission docs (Facebook: pages_show_list,
pages_read_engagement, pages_manage_posts; Instagram: those three plus pages_read_user_content,
instagram_basic, instagram_content_publish; YouTube: .../auth/youtube.upload required,
.../auth/youtube.readonly optional — needed only for the stats extension point). upsertApp() rejects (400) any
unrecognized scope and any submission missing a required one, then joins the array with that platform’s own
separator (SCOPE_SEPARATOR — comma for Meta, space for Google, matching each platform’s own OAuth dialog
convention) before storing — the DB column itself is unchanged, still a single string. Platforms with no exchanger
built yet (X, TikTok) have no catalog entry, so any non-empty list is accepted rather than guessed at.
Frontend: RegisterAppPanel replaced the free-text scopes input with a checkbox list fetched from the scope
catalog — required scopes are pre-checked and disabled (can’t be unchecked, since submitting without one always
400s anyway), so the most common mistake is now structurally impossible rather than just validated after the fact.
Platforms with no catalog fall back to a comma-separated free-text input. The apps list table gained a “Scopes”
column rendering each granted scope’s catalog label (falling back to the raw value for anything not in the catalog,
e.g. a scope later removed from it) — previously scopes weren’t visible anywhere in the UI at all.
Facebook Login for Business vs. classic scope-based login
(SocialPlatformApp.configId). Discovered live against a real Meta App: the “Manage everything on your Page”
use case (Facebook Login for Business) does not grant permissions via the classic scope query parameter at all —
it requires a Configuration ID, created in the Meta App dashboard (Facebook Login for Business product >
Configurations), where the actual permission list lives. Sending scope alongside/instead of config_id on a
Business Login app produces a partial, confusing failure — some permissions silently rejected as “Invalid Scopes”
(Meta’s own error, shown only to developers) rather than a clean success or a clean rejection of the whole request.
configId is a new nullable column on SocialPlatformApp; MetaGraphApiService.buildAuthorizeUrl() sends
config_id instead of scope whenever it’s set, and only falls back to the classic scope param when it’s null —
the two are mutually exclusive on the dialog, never sent together. scopes is still recorded and validated even
when configId is set — useful as a record of what the Configuration is expected to grant, even though it isn’t
what’s literally sent in that case.
Frontend: RegisterAppPanel shows a “Configuration ID” field for FACEBOOK/INSTAGRAM specifically, with guidance
on where to find it in the Meta dashboard; the Scopes field’s label changes to clarify it’s reference-only once a
Configuration ID is set. The apps list table shows a “Business Login (config_id)” badge in place of the scope pills
for any row that has one, so which mode a registered app is in is visible at a glance instead of requiring a DB
query to diagnose the next time this exact class of error shows up.
Bug found and fixed while wiring this up: PlatformSocialAppService.getDecryptedApp() — the one method that
reads SocialPlatformApp for actual OAuth use — passes an explicit column select array (needed to opt back into
clientSecretEncrypted’s select: false), and configId was never added to it. TypeORM silently omits any column
not in an explicit select list, so app.configId came back undefined on the object buildAuthorizeUrl() actually
receives at connect time — meaning the fix above would have compiled, passed its own unit tests (which mock the
repository directly, bypassing this), and still silently done nothing in production. Added configId to the select
list and a regression test asserting it’s present, specifically because this class of bug — correct in isolation,
broken through one specific read path — doesn’t show up any other way.
Edit and delete for a registered app. Neither existed before this — the only way to “edit” was re-registering
blind for the same platform (a full overwrite via the same upsert, requiring every field retyped including a
Client Secret that’s never returned by any GET), and there was no way to remove a platform app at all, only
deactivate it. DELETE /platform/social-media-apps/:platform is a genuine hard delete (see routes table above) —
safe specifically because SocialAccount has no FK to SocialPlatformApp, only a plain platform enum string, so
nothing relational can orphan; an already-connected tenant’s stored tokens are untouched either way, identical to
deactivating. RegisterAppPanel now accepts an editingApp prop: platform is locked (can’t retarget an edit to a
different platform — delete and re-register instead), Client ID/Redirect URI/Configuration ID pre-fill from the
existing row, and stored scopes are parsed back into the checkbox picker using that platform’s catalog separator.
Client Secret still can’t be pre-filled (never returned by the API) and must be re-entered to save any change,
same constraint the upsert endpoint always had. The apps list table gained Edit (pencil) and Delete (trash, with a
native confirm() warning what deleting affects) actions alongside the existing Deactivate/Reactivate toggle.
Meta Data Deletion Callback (src/social-media/service/meta-data-deletion.service.ts,
controller/meta-data-deletion.controller.ts). Meta’s Platform Terms §3(d)(i) require every app to either
periodically process a manual list of app-scoped user IDs to purge, or implement a Data Deletion Callback URL that
automates it — registered in the Meta App dashboard’s Advanced settings as the “Data Deletion Request URL”. Meta
POSTs application/x-www-form-urlencoded with a single signed_request field
({base64url_sig}.{base64url_json_payload}, HMAC-SHA256 signed with the app’s client secret) whenever a user
deauthorizes the app or requests deletion from their Facebook Account Settings.
MetaDataDeletionService.verifySignedRequest() tries the signature against every registered Meta-platform app’s
secret (Facebook, then Instagram — both typically share one Meta App, tried independently in case they were ever
registered with different credentials), using timingSafeEqual on equal-length buffers, and rejects (throws
BadRequestException, never silently no-ops) anything malformed, unsigned, or signed with an unrecognized secret —
a forged POST to this public URL must never appear to succeed. PlatformSocialAppService.getDecryptedApp() supplies
the plaintext secret (same decrypt-on-demand pattern the OAuth exchange flow already uses).
The actual “deletion” is close to a no-op by design: SocialAccount.externalAccountId stores the connected Facebook
Page’s id, never the authorizing person’s own Facebook-scoped user id, and connectedBy is our own internal
Admin FK, not a Meta identifier — so there is structurally nothing in Discuva’s database keyed to the user_id
Meta’s signed_request identifies. recordRequest() just persists a SocialDataDeletionRequest row (public schema,
no tenant context — same reasoning as SocialOAuthCallbackController) so the status URL Meta’s response contract
requires isn’t a dead link, and returns a confirmation code immediately; no async job needed. GET .../data-deletion/status/:code renders a small human-readable HTML page (Meta’s contract explicitly requires a
person be able to read it, not just machines) explaining that no personal data was retained.
| Method | Route | Auth | Notes |
|---|---|---|---|
| POST | /integrations/social/meta/data-deletion |
@Public(), tenant-excluded |
Meta calls this directly; body is form-encoded signed_request, not JSON. Responds {url, confirmation_code} |
| GET | /integrations/social/meta/data-deletion/status/:code |
@Public(), tenant-excluded |
Human-readable HTML status page — the URL returned above |
META_DATA_DELETION_STATUS_BASE_URL (optional env var, matching YOUTUBE_WEBSUB_CALLBACK_URL’s own
Joi.string().uri().allow('').optional() convention) supplies the public base URL used to build the status link;
falls back to the incoming request’s own protocol/host when unset, which is fine for local testing but not behind a
proxy that rewrites those.
Utility Module
Shared infrastructure used across the entire application.
Idle-queue Redis load (BullModule.forRootAsync in app.module.ts): Bull runs three independent per-queue background timers (drainDelay, guardInterval, stalledInterval — confirmed unrelated to each other in bull/lib/queue.js: drainDelay is a BRPOPLPUSH blocking-timeout argument, guardInterval drives a self-rescheduling delayed-job setTimeout chain, stalledInterval is a plain setInterval) that hit Redis on a fixed schedule the moment a queue’s .process() handler is registered — completely independent of whether any job is ever enqueued. Left at Bull’s defaults (5s/5s/30s) across this app’s 7 queues, that’s on the order of 250k+ idle Redis commands/day, which is what exhausted the production Upstash request quota with zero tenants live (2026-08-12) — not tenant traffic. Fixed via the shared root settings: drainDelay/guardInterval pushed to their practical max (3600s / 3600000ms) since both have zero real latency cost at any value — Redis’s blocking BRPOPLPUSH wakes immediately the instant a real job is pushed regardless of the polling ceiling, and guardInterval’s ceiling self-adjusts down whenever a delayed job is actually scheduled (none exist anywhere in this codebase — no call site uses Bull’s delay/repeat job options). stalledInterval is the one setting with a genuine trade-off (how long a crashed worker’s job sits unclaimed before Bull reclaims and retries it), kept at 600000ms (10 min) — a non-issue given the single always-on machine and short-lived handlers this app uses. The follow-up queue defines its own settings object (needed for its longer lockDuration), which fully replaces rather than merges with the root config, so all three values are repeated there explicitly (its stalledInterval was previously a bespoke 60000ms with no documented reason for the faster recovery — aligned to the same 600000ms as every other queue).
Bull Board (queue dashboard): Mounted at GET /queues on the NestJS HTTP server. Provides a standalone web UI showing all six queues (email, push-notifications, follow-up, tithe, finance-reconciliation, audit-log) with pending/active/completed/failed job counts and per-job retry controls. Protected by HTTP Basic Auth (BULL_BOARD_USER / BULL_BOARD_PASSWORD env vars). If either env var is absent the dashboard is not mounted. Registered before Helmet so the /queues path is exempt from the strict Content Security Policy.
Email queue (EmailQueueService + EmailProcessor): All outbound email goes through a Bull queue backed by Redis. EmailQueueService.queueEmailWithTemplate() compiles the HTML template using Handlebars and adds a job to the email queue. The platform-wide default email provider is resolved at startup from EMAIL_PROVIDER and injected via EMAIL_PROVIDER_TOKEN; per-send, EmailProcessor may instead resolve a tenant’s own BYOK provider — see “Email BYOK send path” under Communication Providers above. Five providers are available — see the table under Communication Providers above — all accepting optional per-call BYOK credentials. Bull handles retries automatically — 5 attempts, 5-second fixed backoff. On success or permanent failure, a row is written to email_logs with the provider field set to whichever provider actually processed that specific job. Writing that log row is wrapped in its own try/catch in both onCompleted and onFailed — a failure there (e.g. a missing column from an unapplied migration) is logged and swallowed rather than propagating, since these are Bull event handlers with no request context to catch an unhandled rejection; letting one crash the process over an audit-trail write is worse than losing that one log row.
Per-tenant branding (church_name/church_address/logo_url/support_email): resolved fresh per email by EmailQueueService.resolveBrandingData(), not read once at boot — the HTML is fully rendered before the job is queued, so this happens at enqueue time via cls.get('tenantId') → Tenant row lookup, cached under tenant-branding:${tenantId} (CACHE_TTL_REFERENCE_SECONDS, same TTL/pattern as PlanGuard’s plan-features:${tenantId} cache). Any write to a tenant’s name/logo/tagline/address (PATCH /tenant/info, POST/DELETE /tenant/logo, platform-admin’s PATCH /platform/tenants/:id) must invalidate this same key or emails keep stale branding for up to the TTL — all four call sites already do. A tenant field left unset (null) falls back to that one field’s CHURCH_NAME/CHURCH_ADDRESS/LOGO_URL env default, not the whole record — except support_email, which has no env fallback at all (empty string when unset) since a wrong/generic contact address would be actively misleading, not just generic; the 43 member/worker-facing templates that reference it gate the line behind {{#if support_email}} so it’s simply omitted rather than showing nothing useful. product_name always comes from PRODUCT_NAME (the SaaS product name) regardless of tenant — it’s platform-wide, not per-church. Bug fixed (2026-08-04): five templates (tithe-proof-{confirmed,declined,submitted}, pledge-contribution-{confirmed,declined}) had a hardcoded external logo URL instead of {{logo_url}} — every tenant’s emails from those five showed the same wrong logo, not their own. annual-giving-statement.html had no logo image at all. Both fixed; all 61 templates now reference {{logo_url}}. Formerly a known gap, now fixed: every @Cron scheduler in the codebase that touches tenant-scoped data (FollowUpScheduler included) now wraps its per-run body in forEachActiveTenant() (src/tenant/utility/for-each-active-tenant.ts), which fetches every active Tenant and re-enters that tenant’s CLS/SET LOCAL search_path context (via the existing runInTenantContext() helper) once per tenant before running the body — so branding, currency, and every other tenant-scoped lookup inside a scheduled job now resolves correctly per tenant instead of falling back to env defaults or reading the wrong schema. One tenant’s failure is caught and logged per-tenant so it doesn’t stop the rest of the batch. Only YoutubeSubscriptionScheduler (genuinely control-plane data) and SubscriptionLapseScheduler’s top-level query (also control-plane) are exempt.
Tenant-aware login URLs (login_url/admin_login_url, added 2026-08-04, admin_login_url mechanism changed 2026-08 — Phase 9l): LOGIN_URL/ADMIN_LOGIN_URL are configured as bare base URLs — every caller used to read them straight from ConfigService and pass the bare value into a template, which meant every tenant’s login links pointed at the same non-tenant-scoped host. resolveBrandingData() auto-injects both into every email, but the two now use different rewriting mechanisms, not the same one:
login_url(discuva-member — a real per-tenant wildcard):buildTenantUrl()(src/tenant/utility/tenant-url.ts) inserts the subdomain as the leftmost host label —https://discuva.org/login→https://church-alpha.discuva.org/login.admin_login_url(discuva-admin — a single fixed host, no wildcard):buildAdminUrl()(same file) instead adds the subdomain as a?subdomain=query param, since there’s no per-tenant host to prepend it onto anymore —https://admin.discuva.org/login→https://admin.discuva.org/login?subdomain=church-alpha. discuva-admin’s login/set-password forms read this param to pre-fill their “Church Subdomain” field.
All ~17 call sites that used to compute these themselves (AuthService, AdminService, MemberService, MemberImportService, IncidentReportService, and four schedulers) were simplified to rely on the auto-injected value instead — a call site should never read LOGIN_URL/ADMIN_LOGIN_URL from ConfigService directly. The one exception is AuthService.sendSessionSecurityAlert(), which has to pick between the two based on which surface (member/admin) a session belongs to — it calls UtilityService.resolveTenantLoginUrl('member' | 'admin') (delegates to EmailQueueService.resolveTenantUrl(), which internally branches to buildTenantUrl/buildAdminUrl the same way resolveBrandingData() does) directly instead. TenantProvisioningService.sendWelcomeEmail()'s set-password link is a separate case again — it builds its URL from the Tenant object already in hand (buildAdminUrl(ADMIN_LOGIN_URL, '/set-password', { email, otp, subdomain: tenant.subdomain })) rather than through CLS, since it can run from a Bull job or a synchronous platform-admin call with no tenant CLS context active; the path argument replaces ADMIN_LOGIN_URL’s own path rather than appending onto it, which also incidentally fixed a pre-existing /login/set-password double-path bug the old string-concatenation version had. PLATFORM_LOGIN_URL is deliberately untouched — platform admins aren’t tied to any tenant.
Tenant-aware email subject lines (EmailQueueService.resolveChurchName()/UtilityService.resolveChurchName(), added 2026-09-05): Same class of bug as the login-URL one above, just for the SUBJECT line rather than the body. A template body already gets the correct per-tenant {{ church_name }} automatically via resolveBrandingData(), but a subject is a plain string built in TypeScript before any template is touched — six services (MemberService, RequestLeaveService, AttendanceService, AuthService, MemberImportService, ChildrenChurchService) instead cached PRODUCT_NAME/CHURCH_NAME once in their own constructor from ConfigService and interpolated that into the subject directly. Two symptoms: (1) most of these used PRODUCT_NAME (e.g. a worker’s promotion email read “Welcome to Discuva Workforce” instead of naming their own church), and (2) even the ones already using CHURCH_NAME showed the same single env-configured value for every tenant on the platform, not the recipient’s actual church. EmailQueueService.resolveChurchName() (public — resolves the current tenant via the same CLS + tenant-branding cache getCurrentTenant() already uses, falling back to env CHURCH_NAME only when there’s no tenant context) and its UtilityService delegate replace all ~20 affected subject-line interpolations across those six files; each caller does const churchName = await this.utilityService.resolveChurchName(); once before building the subject (once per request/batch, not once per recipient in a loop). Every now-dead private readonly productName/ConfigService constructor plumbing that had no other use in its file was removed alongside it (RequestLeaveService and ChildrenChurchService no longer inject ConfigService at all).
Domain map (discuva.org)
| Host | App | Notes |
|---|---|---|
discuva.org, www.discuva.org |
Homepage/marketing + self-serve signup | Bare root — extractSubdomain returns null, so tenant-aware URL helpers fall back to the bare base URL here too. |
platform.discuva.org |
discuva-platform | platform is in RESERVED_SUBDOMAINS on both frontend and backend — no tenant can ever claim it. No tenant logic in this app at all (confirmed: no middleware.ts, no Host-header parsing anywhere) — every route it calls is /v1/platform/*, already excluded from TenantMiddleware. |
{tenant}.discuva.org |
discuva-member | Real per-tenant wildcard for the app’s own hosting — extractSubdomain resolves it the normal way, unchanged. discuva-member’s outgoing API calls no longer need to share this host: they target api.discuva.org directly, carrying the subdomain as an X-Tenant-Subdomain header (pre-auth) or a JWT tenant claim (authenticated) instead of via the URL’s own hostname (Phase 9m). No router/path-split needed in front of this host anymore. |
admin.discuva.org |
discuva-admin | Fixed, single origin — no wildcard needed. See “Fallback resolution for a fixed, non-wildcard host” above: tenant identity travels in the JWT (post-login) or an explicit X-Tenant-Subdomain header (login only), not the hostname, so this app doesn’t need — and structurally can’t use — a per-tenant subdomain of its own. |
api.discuva.org |
discuva-api | Reachable directly by every app, always — discuva-admin’s and discuva-member’s tenant resolution both work over this fixed host (see “Fallback resolution for a fixed, non-wildcard host” above), plus discuva-platform, discuva-web’s POST /v1/signup, third-party webhook URLs (Paystack/Flutterwave/YouTube dashboards), health/docs. |
Only one DNS zone needs wildcard coverage — *.discuva.org, and only for discuva-member’s own hosting, not for
any traffic bound for the API. admin.discuva.org and api.discuva.org are both plain, single DNS records, and
neither needs a router in front of it splitting by path — a design an earlier draft of this table used to describe,
which existed only to work around discuva-admin and discuva-member both needing to reach the API without a
subdomain of their own to carry a tenant on. LOGIN_URL should be configured against the discuva.org zone;
ADMIN_LOGIN_URL against admin.discuva.org (no longer the discuva.org zone — the admin app moved to its own
dedicated host).
Email category gating: queueEmail* methods accept an optional category?: EmailCategory argument. If no category is supplied the email always sends (used for security-critical auth emails: OTP, password reset, account locked, etc.). Optional categories are gated by boolean config flags (EMAIL_*_ENABLED); setting a flag to false suppresses that category without touching any call sites. Current categories:
| Category | Flag | Default |
|---|---|---|
ATTENDANCE_CHECKIN |
EMAIL_ATTENDANCE_CHECKIN_ENABLED |
true |
BIRTHDAY |
EMAIL_BIRTHDAY_ENABLED |
true |
EVENT_REMINDER |
EMAIL_EVENT_REMINDER_ENABLED |
true |
PRAYER_REMINDER |
EMAIL_PRAYER_REMINDER_ENABLED |
true |
FOLLOW_UP |
EMAIL_FOLLOW_UP_ENABLED |
true |
ASSET_ALERTS |
EMAIL_ASSET_ALERTS_ENABLED |
true |
GIVING_RECEIPT |
EMAIL_GIVING_RECEIPT_ENABLED |
true |
FINANCE_ALERTS |
EMAIL_FINANCE_ALERTS_ENABLED |
true |
SESSION_REPORT |
EMAIL_SESSION_REPORT_ENABLED |
true |
INCIDENT_REPORT |
EMAIL_INCIDENT_REPORT_ENABLED |
true |
CHILDREN_CHURCH |
EMAIL_CHILDREN_CHURCH_ENABLED |
true |
LOGIN_ALERT |
EMAIL_LOGIN_ALERT_ENABLED |
true |
SERVICE_PROGRAMME_ASSIGNMENT |
EMAIL_SERVICE_PROGRAMME_ASSIGNMENT_ENABLED |
true |
PASTOR_FEEDBACK |
EMAIL_PASTOR_FEEDBACK_ENABLED |
true |
ASSIGNMENT_REMINDER |
EMAIL_ASSIGNMENT_REMINDER_ENABLED |
true |
CLASS_SESSION_REMINDER |
EMAIL_CLASS_SESSION_REMINDER_ENABLED |
true |
FORM_SUBMISSION |
EMAIL_FORM_SUBMISSION_ENABLED |
true |
SUNDAY_SCHOOL_QA |
EMAIL_SUNDAY_SCHOOL_QA_ENABLED |
true |
SUNDAY_SCHOOL_ATTENDANCE |
EMAIL_SUNDAY_SCHOOL_ATTENDANCE_ENABLED |
true (push-only: check-in open, weekly absentees) |
TRAINING_CLASSES |
EMAIL_TRAINING_CLASSES_ENABLED |
true (push-only: join request approved/declined, certificate ready) |
EVANGELISM |
EMAIL_EVANGELISM_ENABLED |
true (push-only: added to an outreach team, convert(s) assigned) |
NOTES |
EMAIL_NOTES_ENABLED |
true (push-only: evening after a service, Monday weekly step; members can also opt out) |
DEPARTMENT_GOAL_ACTIVITY |
EMAIL_DEPARTMENT_GOAL_ACTIVITY_ENABLED |
true (push-only for now — see Department Goals) |
Template files live in src/utility/templates/*.html and use {{variable}} for simple substitution, {{#if}} for
conditionals, and {{#each}} for loops. Values are HTML-escaped automatically; use {{{variable}}} only for
intentional raw HTML.
Calendar invites (.ics) — buildIcsEvent, src/utility/util/ics-builder.ts: a small, dependency-free
builder (BEGIN:VCALENDAR/VEVENT text assembly, no npm ics package) shared by every feature that emails someone
about a specific dated event. Takes { uid, startTime, endTime, summary, description, location? } and returns a
Buffer — pass it to UtilityService.sendEmailWithAttachment(to, subject, templateName, templateData, [{ filename, content }], category?)
(→ EmailQueueService.queueEmailWithTemplateAndAttachments) alongside the usual templated email. The builder
doesn’t own event identity — uid is the caller’s full string (e.g. `${slotId}@service-programme`), so a
recipient’s calendar app can tell “this is an update to an event I already have” from “this is a new event” based
entirely on whether the caller reuses the same uid across sends. Two consumers today:
- Service Programme (
ServiceProgrammeService.notifySlotAssignment,ServiceProgrammeReminderScheduler) —uid:`${slotId}@service-programme```, attached whenever the underlyingServiceSlothas both astartTime/endTime; skipped otherwise (no time range to build an event from). - Classes (
ClassSessionReminderScheduler) — see the Classes Module reminder-scheduler notes below;ChurchClasshas no explicit session-duration field, so this consumer defaultsendTimetostartTime + 1hrather than omitting the invite.
Cloudinary (CloudinaryService): Streams file uploads to Cloudinary via upload_stream with resource_type: 'auto'. Used for finance request attachments, payment proofs, and tithe payment proofs. uploadBuffer(buffer, folder, filename?) returns {secureUrl, publicId, resourceType} — callers must persist publicId and resourceType so that assets can be deleted without re-parsing the URL. deleteByPublicId(publicId, resourceType) destroys the asset using the stored values (replaces the old deleteByUrl which hardcoded resource_type: 'raw'). The service validates all three credentials on module init and throws if any are missing. Credentials are read from CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, and CLOUDINARY_API_SECRET.
Cache (CacheService): A Redis-backed key-value cache. All read operations (get) are awaited — the result is
needed before the request can continue. Write operations (set, del) are fire-and-forget for non-critical
data (cache population after a DB fetch, cache invalidation on mutations) — if the Redis write is lost, the worst
case is a cache miss on the next request, which falls through to the database. Rate-limit reads are always awaited;
rate-limit counter clears and increments are fire-and-forget.
Caching strategy by data type:
| Data | Key pattern | TTL | Invalidation |
|---|---|---|---|
| Department list | departments:all |
CACHE_TTL_REFERENCE_SECONDS |
On any CRUD |
| Venue list | venues:all |
CACHE_TTL_REFERENCE_SECONDS |
On any CRUD |
| Event config list | event-config:all |
CACHE_TTL_REFERENCE_SECONDS |
On any CRUD |
| Leaderboard | leaderboard:{days}:{limit} |
CACHE_TTL_LEADERBOARD_SECONDS |
TTL only |
| Rate limit keys | login_fail:{email} etc. |
Per-window duration | On success |
Birthday Module
Automatically greets members on their birthday with an email and a congregation-wide announcement. Other members can send personal wishes that persist permanently in the member’s birthday book.
Cron: Runs daily at 6 AM. Queries all active members whose birthMonth and birthDay match today and whose birthdayGreetedYear is not the current year, then for each member (in an isolated try/catch):
- Creates an
ALL-audience announcement withexpiresAt = 23:59:59tonight - Updates
birthdayGreetedYearto the current year (only after the announcement saves) - Sends the birthday email (fire-and-forget via email queue)
Resilience: BirthdayService implements OnApplicationBootstrap. On startup, if the hour is ≥ 6, it fires triggerBirthdayGreetings() as a background task (fire-and-forget, guarded by a separate lock:birthday-catchup Redis lock). This recovers greetings missed because the app was down at 6 AM — the birthdayGreetedYear field prevents re-sending to members already greeted. Per-member isolation means one member’s failure never blocks the rest.
birthdayGreetedYear: Integer column (smallint) on the Member entity. Null for members who have never been greeted. Set to the current year after a successful greeting. The cron and catch-up both filter WHERE birthdayGreetedYear IS NULL OR birthdayGreetedYear != currentYear to skip already-greeted members.
Wish wall: Wishes persist in birthday_wishes regardless of announcement expiry. Rate-limited to WISH_DAILY_LIMIT
wishes per sender per day (default: 20). Input is DOMPurify-sanitized.
Fields returned by /birthday/upcoming (admin-only): id, firstname, lastname, email, phoneNumber, birthMonth, birthDay, birthYear. birthYear is nullable — members aren’t required to disclose it, so the endpoint only ever uses birthMonth/birthDay (recurring, year-independent) to determine “is today/upcoming a birthday”; birthYear is included purely so callers can render a full date when it’s known, falling back to a day+month-only display when it’s not.
Fields returned by /birthday/today (member-facing, BirthdayCelebrant): id, firstname, lastname, birthMonth, birthDay, birthYear, role, departmentName, clergyTitleName, alreadyWishedByMe, photoUrl. Deliberately does not include email/phoneNumber — those are fine for the admin-only /birthday/upcoming view but not for a response every member can call. Same-named celebrants are disambiguated instead via role/departmentName (from workerProfile.department, loaded via the workerProfile and workerProfile.department relations), clergyTitleName (from the clergy.title relation — a flat string, unlike MemberDto’s nested clergy, since this is pure display and never drives a form), and now photoUrl (from Member.photoUrl — see Member Module) — the mobile UI shows the photo when set, falling back to initials.
alreadyWishedByMe on /birthday/today: computed per request from the caller’s own JWT identity (not present on /birthday/upcoming, which is admin-only and has no “sender” concept) — true when the calling member already has a BirthdayWish row for that recipient this calendar year. sendWish() already enforced one-wish-per-sender-per-recipient-per-year at the DB level (@Unique(['recipient', 'sender', 'year']) on BirthdayWish) and rejected a second attempt with 400 — this field just surfaces that same state proactively on load, computed via a single extra query (BirthdayWish.find({ sender, year, recipient: In(todaysBirthdayIds) })) rather than the client only discovering it reactively after a failed second send.
Routes prefix: /birthday
Membership Anniversary Module
Background-only (no controller/routes) — automated congratulatory announcement + email when a member reaches a
join-date anniversary, keyed off Member.dateJoinedChurch rather than createdAt (a member’s system-account
creation date can differ from their actual join date, e.g. for admin-backfilled historical records).
Structurally a clone of the Birthday Module’s mechanics rather than a new pattern: same lock-key/catch-up/daily-cron
shape (@Cron('0 6 * * *'), onApplicationBootstrap catch-up for missed runs, Redis lock to prevent double-sends
across instances), same memberGreetedYear-style dedup marker (Member.anniversaryGreetedYear, mirrors
birthdayGreetedYear). Differs from Birthday in one way: greeting delivery goes through
AnnouncementService.createSystemAnnouncement() (ALL-audience, push-notifies automatically) rather than Birthday’s
older direct-repository-insert pattern, which predates that helper and doesn’t push-notify.
Eligibility: ACTIVE members with a non-null dateJoinedChurch whose join month/day matches today, excluding
the join year itself (a member who joined today this year has “0 years,” not an anniversary), and not already
greeted this calendar year.
Email: membership-anniversary template, EmailCategory.MEMBERSHIP_ANNIVERSARY (togglable via
EMAIL_MEMBERSHIP_ANNIVERSARY_ENABLED, default true, same convention as every other email category).
Audit: MEMBERSHIP_ANNIVERSARY_GREETED per member greeted (metadata: { years }).
Dashboard Module
Aggregated data endpoints per role. Does not store data — assembles from other services.
Routes prefix: /dashboard
Sunday School Module
Manages permanent Sunday School classes, class membership, and session-based attendance. Classes have no graduation — members stay assigned indefinitely. Both teachers and enrolled students can mark attendance, but self-mark requires that a staff member has opened the window on the session.
Key flows:
- Admin or SS-dept worker creates a class and assigns a teacher (optional).
- Members are assigned to a class via the members sub-resource. Assignments are permanent until explicitly removed.
- A session is created per class per date. Staff open a timed self-mark window via
PATCH /sessions/:id/open(body:{ closesInMinutes: 5–480 }); members may self-mark only whileselfMarkClosesAtis non-null and in the future. Staff can close the window early viaPATCH /sessions/:id/close. No cron job required — the window expires automatically at query time. - Bulk marking is used by teachers/staff; self-mark (
POST /sunday-school/sessions/:id/checkin) is used by individual members.
Open sessions for member (GET /sunday-school/sessions/open): each returned session carries a computed
alreadyCheckedIn: boolean — true if the calling member already has a PRESENT attendance record for that session.
A session stays “open” for the whole class regardless of whether this particular member has self-marked yet, so the
flag is what lets the member app grey out the Check In button and show “Checked In” instead of leaving it looking
actionable (and re-throwing BadRequestException on a second tap) after a refetch. Re-marking is still allowed when
the existing record is ABSENT/EXCUSED (a teacher’s pre-mark being self-corrected) — only an existing PRESENT
record sets the flag.
Computed fields on class/session responses (added 2026-09-05, audit fix): SundaySchoolClass isn’t stored with a
member count, and SundaySchoolSession isn’t stored with an open/closed status — both are computed at read time
rather than persisted, so they can’t drift from the actual assignment rows / selfMarkClosesAt value:
membersCount— attached to every class returned byGET /sunday-school/classes,GET /admin/sunday-school/classes, and class create/update, via a single groupedCOUNT(*)query across all requested classes (not N+1). A brand-new class always reports0without a query.selfMarkOpen— attached to every session returned by the sessions-list and single-session routes (both worker and admin controllers), computed the same waygetSessionRosteralready computedselfMarkOpeninternally:!!selfMarkClosesAt && now < selfMarkClosesAt.discuva-admin’s sessions table previously tracked open/closed via a client-onlystatusfield that the API never actually returned, which only reflected reality immediately after that same browser tab called Open/Close — a fresh page load showed every session as closed regardless of its real state. The frontend now readsselfMarkOpendirectly off the API response.
markedAt refreshes on re-mark (fixed 2026-09-05, audit fix): SundaySchoolAttendance.markedAt used to only be
set once, at INSERT — re-marking an existing record (a member self-correcting a teacher’s earlier ABSENT mark via
selfMarkPresent, or a teacher overwriting a status via bulkMarkAttendance/adminBulkMarkAttendance) updated
status/markedByTeacher but left markedAt frozen at the original mark time. Since getMyAttendanceHistory sorts
by markedAt DESC, a corrected record could display a stale timestamp and sort out of chronological order. All three
re-mark paths now set markedAt = new Date() when overwriting an existing record.
Class create/update now validates teacherId (fixed 2026-09-05, audit fix): createClass/updateClass and their
admin equivalents previously set the teacher relation from a raw teacherId with no existence check (unlike
assignMember, which already validated the member exists) — an invalid id surfaced only as a raw FK-constraint 500.
Both now throw a clean NotFoundException('Teacher not found') up front.
Session-creation race hardened (fixed 2026-09-05, audit fix): createSession/adminCreateSession check-then-insert
against the DB’s (class, sessionDate) unique constraint; a genuine race between two concurrent creates for the same
class+date now surfaces the same friendly ConflictException message instead of a raw Postgres 23505 error.
First-timer check-in (POST /sunday-school/sessions/:id/checkin-first-timer; admin twin POST /admin/sunday-school/sessions/:id/checkin-first-timer): lets a teacher (unless the church turned teachersCanCheckInFirstTimers off) or an admin
check in someone with no Member record at all — a visiting child/family whose first-ever contact with the church is
literally the Sunday School class, not the main service. SundaySchoolAttendance.member is now nullable, with a new
nullable firstTimer FK (follow_up.FirstTimer) alongside it — DB-enforced XOR (CHK_sunday_school_attendances_ member_xor_first_timer): every row has exactly one of the two, never both, never neither. Two independent UNIQUE
constraints, (session, member) and (session, firstTimer), not one composite — Postgres treats NULL as distinct
per row, so a nullable column already tolerates unlimited NULLs without help from the other column.
- Calls
FollowUpService.createFirstTimerFromSundaySchoolCheckIn()— a new method that calls the same privatedoCreateFirstTimer()every other first-timer creation path uses (round-robin assignment to an active Follow-Up worker,FollowUpTaskcreation, fire-and-forget assignment email) but skipsassertWorkerInFollowUpDept’sMANAGE_FOLLOW_UPcapability gate — a Sunday School teacher has no reason to hold that capability, andSundaySchoolService.requireSundaySchoolAuthalready authorizes the caller before this is ever reached.sourceis forced to the newFirstTimerSourceEnum.SUNDAY_SCHOOLvalue regardless of what’s submitted, same not-spoofable-from-input patterncreateFirstTimerFromPublicFormalready uses forONLINE. - Deliberately not nested inside
bulkMarkAttendance’s pattern of a self-openedthis.attendanceRepo.manager. transaction()—doCreateFirstTimerrelies onthis.txHost.tx, the CLS-ambient transaction FollowUpService manages itself, and mixing that with a second independently-opened transaction risks the tenant schema’sSET LOCAL search_pathnot being visible to whichever transaction manager didn’t set it. The FirstTimer is created as its own step, then a singleSundaySchoolAttendancerow (alwaysPRESENT,markedByTeacher: true) is saved separately. - Fixed the same day:
getSessionRoster/adminGetSessionRosterused to build theirMapkeyed ona.member.idunconditionally — a first-timer attendance row (member: null) would have thrown aTypeErrorthe moment one existed. Both now split attendance rows by which ofmember/firstTimeris set, andSessionRostergained a newfirstTimerCheckIns: {attendanceId, firstTimerId, name, markedAt}[]array so a checked-in guest is actually visible in the roster response, not just recorded invisibly in the DB. - No admin-facing equivalent endpoint yet — this is deliberately worker/teacher-only for now (the actor is always “a Sunday School teacher checking someone in during class”).
Lesson material (SundaySchoolSession.documentUrl): optional link to that date’s lesson material (Google Drive,
PDF link, etc.) — validated as a URL (@IsUrl()), settable only at session creation (POST .../sessions), same as
the pre-existing notes field — neither has an update-after-creation route. Set via either the worker/teacher
controller (mobile) or the admin controller; both share CreateSundaySchoolSessionDto. Surfaced as “View Lesson
Material” on the teacher’s roster panel (discuva-member mobile) and via a small link icon in the admin sessions table.
Routes prefix: /sunday-school (worker/member routes) and /admin/sunday-school (admin routes)
Admin controller (/admin/sunday-school): All routes require AdminGuard. Provides the same class and session management as the worker controller but bypasses the requireSundaySchoolAuth check so that admins can manage any class regardless of department or teacher assignment.
Questions (Q&A) (added 2026-09-06): Any member assigned to a class can ask a private question against it
(POST /sunday-school/classes/:id/questions) — visible only to the asking student and to Sunday School staff/the
class’s teacher, never shared with the rest of the class. SundaySchoolQuestion (new entity, sunday_school_questions
table) holds questionText, and answerText/answeredBy/answeredAt (all null until answered). No new
AdminPermission was introduced — the existing SUNDAY_SCHOOL_READ/SUNDAY_SCHOOL_WRITE pair already gates every
other Sunday School sub-resource in this module (classes, sessions, roster) the same way, matching the codebase-wide
convention that a module gets one READ/WRITE pair, not one per sub-resource.
GET /sunday-school/my-classes— the classes the calling member is assigned to (needed because nothing previously told a student which classes they’re even in; used to populate the class picker when asking a question).POST /sunday-school/classes/:id/questions—JwtAuthGuardonly; the service itself verifies the caller is assigned to the class (ForbiddenExceptionif not), the same checkselfMarkPresentalready does.GET /sunday-school/classes/:id/questions— teacher/SS-staff view of every question asked in one class (requireSundaySchoolAuth).GET /sunday-school/questions/me— the calling member’s own questions across all their classes, paginated.PATCH /sunday-school/questions/:id/answer— teacher/SS-staff answers a question (requireSundaySchoolAuth).GET /sunday-school/questions— cross-class view of every question across every class (added 2026-09-06, UX follow-up): the per-class endpoint above requires opening one class at a time, which doesn’t scale to “what’s been asked across my classes” for a team of several teachers. Gated onDepartmentAccessService.assertHasCapabilityalone — deliberately notrequireSundaySchoolAuth’s class-teacher fallback, since a teacher who isn’t in the SS department is scoped to their own class’s Q&A only, not everyone else’s private questions too. Any true SS-dept worker sees every class’s questions and answers here.- Admin mirrors under
/admin/sunday-school:GET questions(cross-class, no capability check — admin already bypasses department checks everywhere else in this module),GET classes/:id/questions,PATCH questions/:id/answer(both bypassrequireSundaySchoolAuthlike every other admin method here), plusDELETE questions/:idfor moderation.
Teacher-notification fallback rule: asking a question notifies the class’s assigned teacher (email + push, new
EmailCategory.SUNDAY_SCHOOL_QA, gated by EMAIL_SUNDAY_SCHOOL_QA_ENABLED + the per-tenant category toggle same as
every other category). If the class has no assigned teacher, it instead notifies every member with the
MANAGE_SUNDAY_SCHOOL department capability (push only, no email — a fallback for an unusual state, not worth an
inbox hit for the whole team on every question). This reverse lookup — “who has capability X,” as opposed to
DepartmentAccessService.hasCapability’s existing “does this one member have it” — is a new, reusable
DepartmentAccessService.findMemberIdsWithCapability() method, centralizing a raw capability-join query pattern that
previously existed inline in three other services (FollowUpService, ServiceSessionService). Answering a question
notifies the asking member back the same way (email + push). Both legs go through
NotificationDispatchService.notifyMember() — the shared category-gated dispatcher — not the older, ungated
PushNotificationService.dispatchToMemberIds/sendEmailWithTemplate pair some pre-existing modules (e.g.
PastorFeedbackService) still call directly.
Class details & assistants (added 2026-10-01): create/update (admin or worker) accept ageGroup, meetingDay,
meetingTime, location (send null to clear) and assistantIds: uuid[] (replaces the list; the teacher is dropped
from it if included; unknown ids → 404). requireSundaySchoolAuth’s class-teacher fallback now also passes for an
assistant. GET /sunday-school/my-teaching lists the classes a worker teaches or assists (with membersCount), so the
member app can show a Teach view to teachers outside the Sunday School department.
Editing and recurring sessions (added 2026-10-01): PATCH .../sessions/:id changes sessionDate, notes,
documentUrl (empty string/null clears) and keeps the session’s attendance; moving onto a date the class already has
→ 409. POST .../sessions/series ({ classId, startDate, endDate, everyWeeks?: 1–4, notes? }) creates a session every
N weeks from start to end inclusive (UTC date stepping, so DST never shifts a day), skipping dates that already have a
session; at most 60 per call. Returns { created, skipped: date[], dates: date[] }.
Reports (SundaySchoolReportService, added 2026-10-01): GET /admin/sunday-school/reports/attendance?from&to&classId
— default range is the last 12 weeks; to is capped at today (church timezone), so future sessions never count.
Returns { from, to, summary, byClass, bySession, members? }:
bySession:enrolled(current members who had joined by that date),present/absent/excused,unmarked(enrolled − marked),firstTimers,rate.byClass:enrolled(current),sessions,averagePresent,firstTimers,rate.members(only withclassId): per current member —sessionsHeld(since they joined), counts,rate,lastPresent.rateeverywhere is present ÷ (expected − excused) as a percentage with one decimal, ornullwhen nobody was expected. Members who have since been removed from a class aren’t counted as expected (attendance is measured against current membership).GET .../reports/attendance/exportdownloads the same range as an.xlsxwith Classes, Sessions and Members sheets (members across every class in range).
Absentees (“missing lately”): a member is listed when their most recent N sessions in a row (only sessions since they
joined, up to the last 10, none in the future) were missed — ABSENT or never marked; EXCUSED counts as attended.
Inactive members are skipped. misses defaults to 3 (2–10). Admin: GET /admin/sunday-school/reports/absentees?classId&misses;
teacher/assistant: GET /sunday-school/classes/:id/absentees?misses. Rows: { classId, className, memberId, firstname, lastname, email, phoneNumber, missedInARow, lastAttended }, longest streak first.
Indexes for these queries: reports and absentees use the existing sunday_school_sessions(session_date),
(sunday_school_class_id, session_date) unique, sunday_school_attendances(session_id) and
sunday_school_members(sunday_school_class_id) indexes; assistants have a composite PK plus member_id index. The
candidates search ILIKEs firstname/lastname/email word by word so each branch uses the members trigram indexes, and
bulk add by email uses IDX_members_email_lower (LOWER(email), migration 1799737200000-AddMembersLowerEmailIndex).
Teacher marking window (added 2026-10-01): teachers (worker routes) can mark attendance, open check-in and check in
first-timers for a session only until sessionDate + teacherMarkingDays (church timezone; 0 = the session day only,
default 2). After that those routes return 403 “Attendance for this session closed on YYYY-MM-DD. Ask an admin…”.
Admin routes are never limited. The worker roster (GET /sunday-school/sessions/:id/roster) adds teacherMarkingOpen
and teacherMarkingClosesOn so the member app shows the session read-only. Members’ own self check-in is unchanged — it
is governed only by the timed selfMarkClosesAt window.
Notifications (EmailCategory.SUNDAY_SCHOOL_ATTENDANCE, push only):
SUNDAY_SCHOOL_CHECKIN_OPEN— when check-in opens (teacher or admin), to class members not yet marked for that session. Idempotency keysunday-school-checkin-open:{sessionId}:{closesAt}, so re-opening sends again. A failed push never blocks opening.SUNDAY_SCHOOL_ABSENTEES—SundaySchoolAbsenteeScheduler, Mondays 08:00 church time, every active tenant with thesunday_schoolmodule on: to each class’s teacher and assistants, with how many members have missed 3+ in a row. One per class per week (sunday-school-absentees:{classId}:{date}).
Tithe Module
Enables the finance team to manage bank accounts, upload Excel tithe payment sheets, and review member proof submissions. Members can view their own records, request PDF statements, and submit proof of offline payments.
Account management: The finance team maintains a list of tithe bank accounts (TitheAccount) — one per physical bank account. Each account has its own currency (ISO 4217), enabling the church to accept NGN, USD, and any other currency simultaneously. Members and workers can browse active accounts at GET /tithes/accounts. Admins manage accounts via:
| Method | Route | Permission | Notes |
|---|---|---|---|
POST |
/admin/tithes/accounts |
FINANCE_WRITE |
Create account. 409 if (accountNumber, bankName) already exists. |
GET |
/admin/tithes/accounts |
FINANCE_READ |
Lists all accounts (active and inactive), ordered by currency ASC, bankName ASC. |
PATCH |
/admin/tithes/accounts/:id |
FINANCE_WRITE |
Update account details. |
GET |
/admin/tithes/accounts/:id/summary |
FINANCE_READ |
Aggregate totals for one account. See below. |
Account summary (GET /admin/tithes/accounts/:id/summary): Accepts optional fromMonth / toMonth (YYYY-MM) query params and returns:
{
"account": { "...": "TitheAccount fields" },
"fromMonth": "2026-01",
"toMonth": "2026-06",
"bulkTotal": 500000,
"bulkCount": 45,
"proofTotal": 75000,
"proofCount": 8,
"grandTotal": 575000
}
bulkTotal/bulkCount aggregate confirmed TitheRecord rows whose batch is linked to this account. proofTotal/proofCount aggregate CONFIRMED TithePaymentProof rows linked directly to this account.
Upload flow:
- Finance admin selects a
TitheAccountand uploads.xlsxviaPOST /admin/tithes/upload(multipart, field namefile; body fieldtitheAccountId). - Service validates that the account exists and is active, validates required columns (
Email,Amount,Payment Date), and returns 400 immediately for invalid input. - A
TitheUploadBatchrecord is created (linked to the account, with parsed rows stored as JSONB for safe requeue) and a Bull job (tithequeue,process-batchjob) is dispatched withattempts: 3, removeOnFail: false. - The processor runs asynchronously inside a database transaction: matches each row to a member by email (case-insensitive), creates
TitheRecordfor matches,TitheUnmatchedRecordfor no-match rows, andTitheDisputeRecordfor rows that duplicate an existing record by(memberId, paymentDate, amount). The transaction ensures idempotent retries — a mid-batch failure rolls back all inserts so the next attempt starts from a clean slate. A matched row’s optionalgivingOptioncolumn is looked up once per batch (case-insensitive name match againstGivingOption) and set on the createdTitheRecord; blank or unmatched values leave itnull(falls back to “General Giving” on display) rather than guessing.
Failed batch requeue: If a batch reaches FAILED status, a finance admin can requeue it via POST /admin/tithes/batches/:id/requeue. The stored rows JSONB field is used to reconstruct the job without re-uploading the file.
Excel template: Three-sheet workbook — Tithe Template (headers only), Instructions, Sample. Served at GET /admin/tithes/template. Columns: email, amount, paymentDate (all required), reference, bankName, givingOption (all optional — givingOption must match an existing GivingOption name exactly, case-insensitive).
Admin records list: GET /admin/tithes/records returns all confirmed tithe records (paginated, FINANCE_READ). Supports the following query params:
| Param | Type | Description |
|---|---|---|
memberId |
UUID | Filter to one specific member |
departmentId |
UUID | Filter to tithes paid by workers in that department |
fromMonth |
YYYY-MM |
Start of payment date range (inclusive) |
toMonth |
YYYY-MM |
End of payment date range (inclusive, last day of month) |
search |
string | Wildcard match on member firstname, lastname, or email |
accountId |
UUID | Filter to records tied to a specific tithe account |
page / limit |
int | Pagination (default 1 / 20) |
GET /admin/tithes/records/download accepts the same filters (no pagination) and returns an .xlsx file with columns: Member Name, Email, Account (bank name), Currency, Amount, Payment Date, Purpose (the record’s givingOption.name, or “General Giving”), Sender Bank, Source, Payment Gateway, Reference.
Member visibility: Members view their own tithes at GET /tithes/me (loads batch.titheAccount and givingOption relations so the frontend can label each row correctly — see “Giving Statement” below) and request a PDF statement emailed to them at POST /tithes/me/statement/send (TitheService.emailGivingStatement). Optional query params fromMonth and toMonth (format YYYY-MM) filter the statement date range; givingOptionId (UUID) filters it to one Giving Option. When an option is selected, only matching TitheRecords are included; confirmed pledge contributions are included only in the unfiltered all-giving statement because they are not associated with a Giving Option. If only one date bound is supplied the other is open-ended. Returns { message, recordCount } (200 OK). The email body states the date range and selected option when applicable, alongside the record count.
Members can request a separate pledge contribution PDF at POST /tithes/me/pledge-statement/send. It contains only CONFIRMED contributions from the caller’s pledges, with campaign names, dates, amounts, and references; pending/declined contributions and regular giving are excluded. Optional fromMonth/toMonth (YYYY-MM, by payment month; 400 if malformed or reversed) and campaignId narrow it, and the email states the period and campaign; with none it covers everything to date. If nothing matches, no email is sent and the response says so. (lastDayOfMonth now builds the month end in UTC — it previously slipped back a day on servers ahead of UTC.)
Giving Statement — not just a “Tithe Statement” (added 2026-09-01): the emailed PDF is called “Giving Statement,” not “Tithe Statement” — TitheRecord already holds every type of online/manual giving (Tithe, Offering, General Giving, a GivingOption like “Building Fund”), and calling the whole document “Tithe Statement” wrongly implied it covered only one type. emailGivingStatement also merges in the member’s CONFIRMED PledgeContributions for the same date range — pledge-designated gifts live in a separate table (see Finance Module’s Pledge section) and were previously invisible on this statement entirely, understating a member’s real total giving. Each line gets a Type column via one shared rule (also used by the frontend history list, see discuva-member’s giving.tsx):
- The uploaded bank account’s name when the record came from a bank-statement upload (
batch.titheAccount). - Otherwise the purpose the member chose (
givingOption— set at online checkout, or carried over from a confirmed proof of payment). - Otherwise the sender’s
bankName(legacy manual deposits). - Otherwise “General Giving”.
- A
PledgeContribution→ “Pledge: {campaign name}”.
(Changed 2026-09-30: previously an unmatched MANUAL_PROOF row was always labelled “Tithe”, so a confirmed proof the
member had marked “Offering” showed as a tithe. The same rule is applied in SQL by the History summary and by the
member app’s history list.)
Member proof list filter: GET /tithes/proof takes an optional status query — a comma list of PENDING,
CONFIRMED, DECLINED (unknown values → 400). The member app asks for PENDING,DECLINED only: a confirmed proof is
already a TitheRecord in the giving history, so listing it again would show the same gift twice. Declined proofs
return financeNote so the app can show why.
Member giving summary — GET /tithes/me/summary?year=YYYY (JwtAuthGuard; year optional, defaults to the current
year, 2000–2100): { year, years, total, count, byType: [{ type, total, count }] } for the member’s History tab.
byType covers TitheRecords (labelled with the rule above) and CONFIRMED PledgeContributions (“Pledge: {campaign}”),
largest first; years lists every year with giving plus the current year, for the year picker. Computed with two
aggregate queries (GROUP BY over tithe_records + pledge contributions, and a UNION of distinct years), both using
the (member_id, payment_date) / pledge member indexes — no rows are loaded into memory.
Month groups (2026-09-30): the statement table no longer has a Month column. Rows are grouped by month, newest first; each group opens with a shaded row carrying the month’s subtotal (useful for annual statements). Columns are Date, Type, Amount, Paid Via, Reference; references print at 7.5pt so 43-character online references fit on one line.
Paid Via column (replaces “Bank”): the sender’s bankName for transfers; for online payments the provider from
TitheRecord.paymentChannel (paystack → “Paystack”, flutterwave → “Flutterwave”, kora → “Korapay”,
stripe → “Stripe”) plus, when the provider reported one, the channel from the checkout session (e.g.
“Paystack · Card”, “Paystack · Bank Transfer”), or “Online” if an older gateway row has none. All online gifts on a
statement are resolved with a single giving_checkout_sessions lookup. Online pledge contributions carry their checkout
reference (giving_…), so the provider is looked up from giving_checkout_sessions (public schema). Other pledge
contributions show “—”. The Amount header and total are right-aligned with the figures (the total no longer repeats
the currency, which is in the header), and the table’s columns fit within the page margins.
Offering (finance_offerings) is deliberately excluded — it has no member relation (anonymous in-service collection), so it can’t be attributed to an individual’s personal statement. This is a different feature from the annual POST /finance/me/giving-statement/send (summary-only, previous calendar year, see Finance Module below) — that one already merged TitheRecord + PledgeContribution totals, just without line items or a member-triggered range.
Tithe payment proof: Members and workers submit proof of an offline tithe payment via POST /tithes/proof (multipart, field: file, max 2 MB; body field titheAccountId — the account they paid into). The file is uploaded to Cloudinary and a TithePaymentProof record is created with status PENDING and expiresAt set to TITHE_PROOF_EXPIRY_DAYS days from submission (default 90). Finance team admins review proofs at GET /admin/tithes/proofs and can CONFIRM or DECLINE each one. Confirming creates a TitheRecord (source MANUAL_PROOF, givingOption unset — the proof form doesn’t collect a purpose, so it displays as “General Giving”) so the payment shows up in the member’s own giving history and giving statement, not just the admin’s proof queue. Confirming or declining also triggers an email to the member that includes the bank name and account-level currency. A daily cron at 03:00 (church-local time, see Timezone) (with distributed Redis lock lock:tithe-proof-cleanup) finds all expired proofs (expiresAt ≤ now), deletes each file from Cloudinary using the stored publicId + resourceType, and removes the DB rows.
Routes prefix (admin): /admin/tithes
Routes prefix (member): /tithes
Finance Module
Full double-entry accounting system for the church. All financial data is fund-scoped (RESTRICTED / UNRESTRICTED). Every posted entry has balanced debit and credit lines; the balance is enforced at the service layer before posting, and a DB-level CHECK (current_balance >= -0.01) on finance_accounts is a last-resort safety net.
Core concepts:
| Concept | Description |
|---|---|
| Fund | RESTRICTED or UNRESTRICTED pool of money. Every account, offering, budget, and pledge belongs to a fund. Back-office accounting data only — never exposed to members directly (see GivingOption). |
GivingOption (finance_giving_options) |
Donor-facing “what is this gift for” selector for online giving-checkout (Tithe, Offering, General Giving, Building Fund, etc.) — admin-managed (admin/finance/giving-options, FINANCE_READ/FINANCE_WRITE), member-readable (GET finance/giving-options, active only). Each option carries an optional fund for accounting purposes only — a member never sees the fund’s id/type, matching how PledgeCampaign already surfaces only fundName as a display string, not raw Fund. A TitheRecord created from a PAYMENT_GATEWAY checkout gets givingOption set when the member picked one; null means “General Giving,” no forced default row. |
| AccountingPeriod | A calendar month (year + month). Entries can only be posted to OPEN periods. Closing a period is irreversible by design (only admins with FINANCE_RECONCILE can close or reopen). |
| Chart of Accounts | finance_accounts table. Each account has an optional unique code (e.g. 1001), a type (ASSET / LIABILITY / INCOME / EXPENSE), subtype, normal balance (DEBIT or CREDIT), and an optional fund assignment. code is nullable but unique when provided — 409 if a duplicate code is submitted. |
| JournalEntry | The root transaction record. Must be BALANCED (sum of debits = sum of credits) before posting. Created as PENDING_APPROVAL; a separate admin with FINANCE_APPROVE (who is not the creator — segregation of duties) approves and posts it. |
| JournalEntryLine | One debit or credit line on a journal entry. Linked to an account. journal_entry_id and account_id are both indexed. |
| JournalEntryLink | Polymorphic association table attaching a journal entry to members, departments, service events, external payees, or finance requests. Stored as a separate table to preserve FK integrity and allow multiple associations per transaction. linkType: FINANCE_REQUEST uses a bare financeRequestId UUID column rather than a relation, deliberately avoiding an entity import from the separate finance-request module. |
| ExternalPayee | Tracks global church remittances, vendors, utilities, contractors, government bodies. |
| Offering | Manually-recorded in-person giving (cash + expected transfer amounts). Purpose is an optional givingOption (finance_giving_options) FK — same admin-configured list checkout uses, not a separate hardcoded type — with a nullable legacy type enum column kept only for rows recorded before this unification (never written to on new entries). Omitting givingOptionId on create means “General Giving” — same convention as InitiateGivingCheckoutDto, no seeded row required. fund is derived from givingOption.fund when present; the DTO’s fundId is required whenever that resolution has nothing to fall back to (no option picked, or the picked option has no fund). Also carries an optional member (who physically brought it — null is a legitimate anonymous/basket collection) and serviceEventId (tags the entry to a specific service, no FK relation, just an indexed-free uuid column). Reconciled separately by finance team. fund_id, giving_option_id, and member_id are all indexed. |
| Budget | Scoped to an account + fund. Actuals computed at query time from posted entries. |
| PledgeCampaign / Pledge | Campaign-level targets and per-member pledge commitments. |
| RecurringEntry | Template for entries that repeat weekly / monthly / quarterly. A daily scheduler generates draft entries for due recurring templates. |
| PettyCashReplenishment | Request/approve flow for topping up petty cash accounts. Self-approve is blocked. Approving creates a PENDING_APPROVAL journal entry (debit toCashAccount, credit fromAccount) with idempotency key petty-cash-replenishment:{id}. |
| BankImportProfile | Configurable CSV parsing profile. Stores column indices, date format, delimiter, and amount convention (SIGNED, SEPARATE_COLUMNS, or AMOUNT_WITH_TYPE). One profile can be flagged isDefault. |
Race condition protection:
SELECT FOR UPDATE(pessimistic locking) onfinance_accountsrows during approval and void operations — prevents concurrent writes from losing updates.- Unique
idempotency_keycolumn onfinance_journal_entries— duplicate submissions return409 Conflict. CHECK (current_balance >= -0.01)DB constraint — last-resort guard.
Void / reversal pattern: Voiding a posted entry does NOT delete it. A new reversing entry (equal and opposite lines) is created with entryType = REVERSAL and both entries remain in the ledger. The original entry status becomes VOIDED. Voiding an entry in a CLOSED accounting period throws 400 Bad Request.
Tithe virtual accounts — removed. A dedicated-bank-account-per-member giving mechanism was scaffolded (entity,
stub service, webhook controller) but never implemented beyond NotImplementedException on every method, and the
member app’s card was labeled “Coming Soon.” Deleted entirely rather than finished — replaced by the tenant-owned
Giving Checkout flow below.
Giving Checkout (Tenant-Owned, BYOK) — src/giving-checkout/
A member pays the church directly via a hosted checkout page — Paystack, Flutterwave, Korapay, or Stripe, using the church’s own merchant credentials, never a platform account. Pure BYOK, same shape as Communication Providers: no platform default exists, so the “Give via Checkout” option is simply absent from the member app until a tenant configures and activates one provider.
Provider abstraction (src/giving-checkout/interface/giving-provider.interface.ts):
type GivingProviderCredentials = Record<string, string>; // e.g. Paystack's { secretKey }, Stripe's { secretKey, webhookSecret }
interface IGivingProvider {
readonly providerName: string;
createCheckoutSession(params: {
amountCents: number; currency: string; payerEmail: string; payerName: string;
reference: string; successUrl: string; cancelUrl: string; credentials: GivingProviderCredentials;
}): Promise<{ checkoutUrl: string }>;
verifyAndParseWebhook(rawBody: Buffer, signatureHeader: string, credentials: GivingProviderCredentials): NormalizedGivingEvent;
}
GivingProviderRegistryService (same shape as PaymentProviderRegistryService/SmsProviderRegistryService) holds
all five vendors live simultaneously; GivingCheckoutService resolves which one to use per call from the tenant’s
active TenantGivingProviderConfig.providerId. Credentials are always passed as a call parameter, never injected
from ConfigService — there is no platform merchant account behind any of these.
The four providers (src/giving-checkout/provider/) —
providerId |
Class | Credential shape | Amount unit sent to vendor |
|---|---|---|---|
paystack |
PaystackGivingProvider |
{ secretKey } |
Smallest unit (kobo) — amountCents as-is |
flutterwave |
FlutterwaveGivingProvider |
{ secretKey, secretHash } |
Major unit (naira) — amountCents / 100 |
kora |
KoraGivingProvider |
{ secretKey } |
Major unit (naira) — amountCents / 100 |
stripe |
StripeGivingProvider |
{ secretKey, webhookSecret } |
Smallest unit (cents) — amountCents as-is |
Webhook signature verification differs per vendor: Paystack HMAC-SHA512 over the raw body
(x-paystack-signature); Flutterwave a direct shared-secret string compare (verif-hash, not an HMAC); Korapay
HMAC-SHA256 over just the data object, not the full envelope (x-korapay-signature); Stripe HMAC-SHA256 over
${timestamp}.${rawBody} using a signing secret distinct from the API key, header format t=…,v1=…
(Stripe-Signature). Each is entirely self-verifying — none of the shared “never trust the payload” discipline
below depends on which scheme a given vendor uses.
Entities — all control-plane (public, never a search_path target — same reasoning as
TenantCommunicationProviderConfig/BillingCheckoutSession: the inbound webhook has no Host header/subdomain to
resolve a tenant from, only a :tenantId path param, so these must be resolvable with zero tenant (schema) context):
GivingProvider(giving_providers) — platform-wide catalog, mirrorsCommunicationProvider.TenantGivingProviderConfig(tenant_giving_provider_configs) — one row per (tenant, provider),credentialsEncrypted(jsonb,select: false,EncryptionServiceAES-256-GCM — same encryption as Communication Providers),isActive. Only one provider active per tenant at a time, enforced the identical way as Communication Providers:TenantGivingProviderService.upsertConfig()/setActive()(when activating) run inside a transaction that also deactivates every other config row for that tenant.GivingCheckoutSession(giving_checkout_sessions) — mirrorsBillingCheckoutSessionexactly: primary keyed by the provider’s own reference, recorded at checkout-initiation time (before the member ever reaches the provider’s hosted page) — the webhook only ever confirms/denies a session this row already describes, never a source of truth for amount/member/tenant identity itself.memberId/givingOptionId/pledgeIdare plain UUID columns, not FK-enforced relations —Member/GivingOption/Pledgeall live in the tenant’s own schema, which a public-schema table can’t foreign-key into.givingOptionIdandpledgeIdare mutually exclusive (see below). A legacytitheAccountIdcolumn still exists on the table but is unused — checkout no longer lets a member pick aTitheAccount(see below).- Reported charge details (root migration
AddGivingCheckoutPaymentDetails): on a successful charge the session also stores what the provider reported —providerTransactionId,paymentChannel(card, bank_transfer, ussd…),paidAt,paidAmountCents,paidCurrency,feesCents, andpaymentDetails(jsonb: card type, last 4, issuing bank, gateway message — never full card data). Only Paystack fills these so far (data.id,channel,paid_at,requested_amountfalling back toamount,currency,fees,authorization.*,gateway_response); other providers leave them null.requested_amountis used because Paystack adds its fees toamountwhen a church passes charges to the payer. - Charge check: when the provider reports an amount or currency that differs from the session, the session is set
to
needs_review(newGivingCheckoutStatus.NEEDS_REVIEW) with the reported details saved, an error is logged, and noTitheRecord/PledgeContributionis created — the money was taken, so finance resolves it rather than it being silently recorded at the wrong amount. Providers that report nothing are trusted as before.
Checkout initiation (GivingCheckoutService.initiateCheckout, member-facing, normal in-app request — tenant
context already resolved by TenantMiddleware): resolves the tenant’s active config (cached 300s per tenant,
invalidated on write — identical pattern to SmsCredentialResolverService, joined against GivingProvider and
requiring provider.isActive = true too, not just the tenant’s own config row — see “Giving Providers:
deactivation has real consequences” below), throws 403 GIVING_PROVIDER_NOT_CONFIGURED if none is active, looks
up the member for email/name, resolves currency from CURRENCY_CODE (checkout is single-currency per tenant —
there is no per-transaction currency picker), generates a giving_{uuid} reference, and calls the resolved
provider. Saves a PENDING GivingCheckoutSession row before returning { checkoutUrl }.
Why checkout has no TitheAccount/currency picker (unlike the manual proof-of-payment flow, which does): a
TitheAccount is one of the church’s real named bank accounts, meaningful only when a member is telling the
system which one they manually deposited into for reconciliation (ProofOfPaymentForm). Gateway checkout never
deposits into a specific TitheAccount — the money always settles to the tenant’s configured BYOK merchant
account for that provider — so a dropdown of account names in the checkout flow looked like it controlled where
the money went when it never did; it only silently overrode the charged currency. Removed entirely rather than
kept as “informational.”
Giving purpose designation — givingOptionId / pledgeId (mutually exclusive): a member may optionally
designate the payment at checkout, never both at once (400 Bad Request if both are given):
givingOptionId— validated against the tenant’s activeGivingOptions (404if missing/inactive). On webhook success, the resultingTitheRecord.givingOptionis set to it.pledgeId— validated as one of this member’s ownPledges withstatus = ACTIVE(404if not found/not theirs,400if not active — checkout never auto-creates a pledge on the fly). On webhook success, noTitheRecordis created at all — insteadPledgeService.recordConfirmedContribution()records aPledgeContributionwithstatus = CONFIRMEDdirectly (skipping thePENDING/admin-review stepsubmitContribution()uses for member-self-reported payments, since the webhook has already verified the money actually cleared) and runs the same pledge-auto-complete checkconfirmContribution()does. This keeps online giving and pledge fulfillment as genuinely separate ledgers — see TitheRecord’s own note above.
Neither field set → the resulting TitheRecord.givingOption is null, displayed as “General Giving.”
Giving Providers: deactivation has real consequences (added 2026-08, same pass as Communication Providers’
equivalent above). PlatformGivingProviderService.setActive() (PATCH /platform/giving-providers/:id) mirrors
PlatformCommunicationProviderService.setActive() exactly, minus the channel dimension:
TenantGivingProviderService.listProviders()excludes an inactive provider from the catalog a tenant can newly select, unless that tenant already has a config against it (kept visible —discuva-admin’s giving providers page renders one row per catalog entry, same as its communication-providers page).GivingCheckoutService.resolveActiveConfig()now joinsGivingProviderand requiresprovider.isActive = true, not justconfig.isActive. A deactivated provider genuinely stops accepting new checkout initiations. Deliberately not applied tohandleWebhook— an in-flight checkout that already charged the member on the provider’s own side must still complete and credit the church’sTitheRecordeven if the provider gets deactivated in the interim; rejecting that webhook would take the member’s money without crediting it anywhere, a worse outcome than letting one already-charged transaction finish.setActive()invalidates the 300s cache immediately for every tenant with an active config against the provider (givingProviderCacheKey, extracted as a shared utility for the same reasoncommunicationProviderCacheKeywas — two places already computed the identical string independently) and emails those tenants viaTenantBroadcastService.notifyTenants(), targeted at only the affected tenants.
A tenant’s own TenantGivingProviderConfig row is never touched by any of this.
Webhook handling (GivingCheckoutService.handleWebhook, POST /webhooks/giving/:tenantId/:provider,
@Public(), excluded from TenantMiddleware): no CLS/tenant context exists at all when this fires — tenantId
comes straight from the path param. Looks up that tenant’s own active config for :provider first (verified
credentials before anything else is trusted), decrypts, resolves the IGivingProvider, and calls
verifyAndParseWebhook() (throws on a bad signature). A non-charge.succeeded event marks the matching session
FAILED and returns — never an error response, so the provider doesn’t retry forever. On success: row-locks the
PENDING GivingCheckoutSession by the event’s own reference (idempotent against webhook redelivery — a second
delivery for an already-COMPLETED session finds nothing to lock, safe no-op), flips it to COMPLETED, looks up
the Tenant row for its schemaName, then runInTenantContext()s into that tenant’s own schema purely to write
the resulting TitheRecord (source: PAYMENT_GATEWAY, externalReference = the session id, paymentChannel =
the provider id, batch: null — same “webhook-created records have no batch” shape as reconciliation-imported
rows). This is the only place SMS/email BYOK’s “resolve credentials, dispatch to the right vendor class” pattern
and the tenant-context-entry pattern (normally only seen in Bull processors, via runInTenantContext) are combined
in the same request.
Routes:
| Method | Path | Auth | Permission | Description |
|---|---|---|---|---|
| GET | /finance/giving-providers |
AdminGuard, tenant-scoped |
TITHE_READ |
{ tenantId, catalog, ownConfigs } — ownConfigs never includes credentials. tenantId lets the frontend build this tenant’s own webhook URL ({apiHost}/v1/webhooks/giving/:tenantId/:provider, no subdomain — see webhook route below) to hand to Paystack/Flutterwave/etc, since nothing else on this tenant-scoped surface otherwise exposes the tenant’s own id to itself |
| PUT | /finance/giving-providers/:providerId |
AdminGuard, tenant-scoped |
TITHE_WRITE |
Body { credentials } — upserts and activates, deactivating any other active provider |
| PATCH | /finance/giving-providers/:providerId |
AdminGuard, tenant-scoped |
TITHE_WRITE |
Body { isActive } — enable/disable without touching stored credentials |
| GET | /finance/giving/checkout/provider |
Member JWT | — | { providerId, providerName } | null — whether to show “Give via Checkout” at all |
| POST | /finance/giving/checkout |
Member JWT | — | Body { amountCents, givingOptionId?, pledgeId?, successUrl, cancelUrl } — returns { checkoutUrl, reference } |
| GET | /finance/giving/checkout/:reference |
Member JWT | — | { status, amountCents, currency, purpose, isPledge } for the caller’s own checkout in this church (status: pending/completed/failed/needs_review; purpose: the giving option’s name, the pledge campaign’s name, or “General Giving”); 404 for anyone else’s. Polled by the Give page after the provider redirects back |
Returning from checkout (member app): before redirecting, the app keeps the returned reference in
sessionStorage. On return it reads the query tolerantly — Monnify appends ?paymentReference=… to a redirect URL that
already has ?checkout=success, and Paystack/Flutterwave echo reference/trxref/tx_ref — then polls the status
endpoint (every 2s, up to 10 times) and names what was given, e.g. “We’ve received your ₦500.00 for Tithe” or
“…toward your Building Fund pledge”: completed → thanks the member and refreshes; failed → “no gift was recorded”;
needs_review → “being checked by the finance team”; still pending → says confirmation hasn’t arrived yet without
assuming they paid (Monnify also redirects when the payer closes the page).
| Provider | Webhook → checkout status | Back to the app | Cancel |
|---|---|---|---|
| Paystack | charge.success → completed |
callback_url + reference/trxref |
metadata.cancel_action → ?checkout=cancelled (was wrongly sent as cancel_url, which Paystack ignores; billing fixed too) |
| Flutterwave | charge.completed with status: successful → completed, else failed |
redirect_url + tx_ref |
returns to the same link with status=cancelled, which the app treats as cancelled |
| Korapay | charge.success → completed |
redirect_url + reference |
— (falls back to the unconfirmed message) |
| Stripe | checkout.session.completed with payment_status: paid, or checkout.session.async_payment_succeeded → completed; async_payment_failed / expired → failed; an unpaid completed session (delayed method such as a bank debit) stays pending |
success_url, no reference (the app uses the one it saved) |
cancel_url → ?checkout=cancelled |
| Monnify | SUCCESSFUL_TRANSACTION PAID → completed; part/over-paid → needs_review |
redirectUrl + ?paymentReference= (appended as a second ?) |
— (falls back to the unconfirmed message) |
Stripe setup: the church’s Stripe webhook endpoint must subscribe to checkout.session.completed,
checkout.session.async_payment_succeeded, checkout.session.async_payment_failed and checkout.session.expired
(listed on the admin’s Giving Providers page). Without the async events a delayed payment stays pending rather than
being recorded.
| POST | /webhooks/giving/:tenantId/:provider | None (per-vendor signature) | — | Provider webhook — creates a TitheRecord on a verified successful charge |
Both finance/giving-providers and finance/giving/checkout are gated behind @RequiresModule('tithe') —
disabled entirely if a church has turned off the Tithe & Giving module.
discuva-admin’s Giving Providers page shows a read-only, copyable “Webhook URL” field per provider (inside the
same Configure/Edit Credentials panel as the credential inputs) — built client-side from tenantId (now returned
above) and NEXT_PUBLIC_API_URL, deliberately not getTenantApiBaseUrl()'s subdomain-prefixed variant, since
the webhook route itself has no subdomain to resolve a tenant from.
Platform-admin visibility (PlatformGivingProviderService, PlatformAnalyticsService.getGiving): the platform
operator’s own “full overview” across every tenant, mirroring Communication Providers’ and Billing’s existing
platform-support surfaces —
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /platform/giving-providers |
BILLING_READ |
List the platform-wide giving-provider catalog. |
| POST | /platform/giving-providers |
BILLING_WRITE |
Register a new provider — { id, name }. |
| PATCH | /platform/giving-providers/:id |
BILLING_WRITE |
{ isActive } — activate/deactivate. See “Giving Providers: deactivation has real consequences” above. |
| GET | /platform/tenants/:id/giving-providers |
BILLING_READ |
This tenant’s configured giving provider(s) and active status — never credentials. Reuses BILLING_READ (giving-checkout is a money concern) rather than adding a dedicated permission for one lookup — same reasoning now extended to the three routes above. |
| GET | /platform/analytics/giving |
ANALYTICS_READ |
?period=&months= — { period, totals, byProvider, byTenant, trend }, every array grouped by currency — completed sessions only, never blended across currencies (a Stripe/USD tenant summed against a Paystack/NGN one would be meaningless). totals is all-time; trend is windowed by months. |
PlatformAnalyticsService.getAdoption() also gained givingAdoption: ChannelAdoption (distinct-tenant count with
an active TenantGivingProviderConfig, no channel filter needed unlike SMS/email since giving-checkout has only
the one implicit channel) — same shape as the existing smsAdoption/emailAdoption.
Env vars: none — pure BYOK, no platform-default credentials for any of the five vendors, so nothing is
env-driven here at all (contrast SMS’s TERMII_BASE_URL, which stays env-driven only because it’s infrastructure,
not a secret — none of these five vendors have an equivalent fixed-but-non-secret host worth externalizing).
Monnify (Moniepoint) — monnify (added 2026-09-30, root migration AddMonnifyGivingProvider):
MonnifyGivingProvider. Credentials { apiKey, secretKey, contractCode } (Monnify dashboard → Developer → API Keys &
Contracts). Sandbox vs live is chosen by the key itself — MK_TEST_ keys use https://sandbox.monnify.com, anything
else https://api.monnify.com. Starting a checkout signs in first (POST /api/v1/auth/login, Basic apiKey:secretKey)
for a bearer token, cached in memory per key pair until a minute before it expires, then
POST /api/v1/merchant/transactions/init-transaction (amount in naira, paymentReference = our giving_… id,
card/transfer/USSD) and redirects to checkoutUrl. Webhooks go to the same v1/webhooks/giving/:tenantId/monnify
URL the admin page shows; the monnify-signature header is HMAC-SHA512 of the raw body keyed by the secret key.
SUCCESSFUL_TRANSACTION with paymentStatus PAID completes the gift; PARTIALLY_PAID/OVERPAID are passed through
with the amount actually paid so the charge check holds them as needs_review; FAILED/EXPIRED/CANCELLED/ABANDONED
fail a pending checkout; everything else (refunds, settlements) is ignored. Reported details are stored like
Paystack’s: transactionReference, channel (card / bank_transfer / ussd), paidOn, currency, fees
(amountPaid − settlementAmount) and card type/last 4. A PAID status is treated as Monnify’s confirmation of the full
amount (so passing fees to the payer doesn’t trip the check). Statements show “Monnify · Card” etc. Like Kora/Stripe,
written against Monnify’s documented API and not yet exercised against live sandbox credentials.
Not built yet: Kora/Stripe integrations are written against each vendor’s documented API shape but have not been exercised against live sandbox credentials (same “documented reasoning, not guessed silently” caveat already attached to Paystack/Flutterwave’s own subscription-webhook gaps elsewhere in this doc) — worth a live smoke test before a tenant relies on either in production. discuva-admin’s Giving Providers settings page, discuva-member’s “Give via Checkout” card, and discuva-platform’s tenant-detail “Giving Provider” panel + analytics “Giving Checkout” section are all built.
CSV reconciliation (bank statement import):
Bank Import Profiles (finance_bank_import_profiles) make CSV parsing bank-agnostic. A profile stores the delimiter, number of header rows to skip, column indices for date/narration/amount, the date format (YYYY-MM-DD, DD/MM/YYYY, DD-MM-YYYY, MM/DD/YYYY), and the amount convention:
| Convention | Description |
|---|---|
SIGNED |
Single column; negative value = debit, positive = credit |
SEPARATE_COLUMNS |
Separate debit and credit columns; whichever is non-zero wins |
AMOUNT_WITH_TYPE |
Amount column + type indicator column (e.g. DR/CR) configurable per profile |
One profile can be flagged isDefault. Upload accepts optional ?profileId query param; if omitted the default profile is used. A 400 error with {firstFailure: {row, column, expected, found}} is returned synchronously (before any job is created) if the file cannot be parsed by the selected profile — books are never affected by an unrecognisable file.
PATCH /admin/finance/reconciliation/jobs/:jobId/rows/:rowId/confirm stages a row by linking it to a ledger account (confirmedAccount). POST /admin/finance/reconciliation/jobs/:id/post-confirmed creates one PENDING_APPROVAL journal entry per confirmed row using bankAccountId + accountingPeriodId from the request body. Each row gets idempotency key reconciliation-row:{rowId}; re-calling the endpoint is safe.
Posting is batched on the read side, per-row on the write side (deliberately). ReconciliationService.postConfirmedRows resolves which rows are already posted in one batched query up front (instead of one idempotency lookup per row), but each row’s actual posting (journal entry + 2 lines + row status update) still runs in its own transaction. This is intentional, unlike the fully-batched bulk operations elsewhere in the codebase: a bad row in a bank-import batch (e.g. a stale account reference) shouldn’t block the rest of the batch from posting, so rows remain independent units of work. The idempotency_key column’s DB-level UNIQUE constraint — not the batched pre-check — is the actual guard against double-posting under a race (e.g. the endpoint invoked twice concurrently); a unique-violation on insert is caught and treated the same as “already posted.”
A row fingerprint (sha256 of date+narration+amount+creditDebit) prevents duplicate rows within the same job. A transaction fingerprint (sha256 of date+amount+creditDebit) prevents the same transaction appearing across different upload jobs.
Admin-configurable profile endpoints (FINANCE_RECONCILE permission):
POST /admin/finance/bank-import-profiles— create profileGET /admin/finance/bank-import-profiles— list all profilesGET /admin/finance/bank-import-profiles/:id— get one profilePATCH /admin/finance/bank-import-profiles/:id— update profileGET /admin/finance/bank-import-profiles/:id/template— download a pre-filled CSV template with correct column headers and two sample rows for the profile
Annual giving statements: Gated by ANNUAL_GIVING_STATEMENT_ENABLED (default false). When enabled, a cron fires on January 1st at 08:00 (with distributed Redis lock) and emails each active member a summary of their total giving for the previous year, using the annual-giving-statement.html template. Members can also trigger their own statement on demand via POST /finance/me/giving-statement/send regardless of the env var flag; this endpoint now returns a message field describing the outcome (sent, or “no recorded giving for {year} yet”).
fetchMemberTotals() sums directly from the actual giving records — TitheRecord (all of a member’s tithes in the date range) plus PledgeContribution with status = CONFIRMED (joined through Pledge for member_id) — merged in-memory by member. This intentionally does not go through finance_journal_entry_links: that link table is only ever populated by fully-manual journal entry creation (JournalEntryService) — bank reconciliation, offering auto-journal, and tithe recording never create one — so a link-based total would be 0 or wildly incomplete for almost every member. GET /admin/finance/reports/member-giving (an admin-facing report, distinct from this member-facing statement) still uses the finance_journal_entry_links path deliberately — it’s a strict “show me actual posted GL lines linked to this member” audit view, not a giving total, and carries the same underlying limitation by design until/unless tithes and offerings get their own automatic journal-linking.
The annual-giving-statement.html template was also silently rendering with a blank church name/address and no currency symbol — it referenced {{ churchName }}/{{ churchAddress }} (camelCase) and {{ currency }}, but EmailQueueService.compileTemplate() only ever injects church_name/church_address/logo_url (snake_case), and the scheduler never passed currency. Handlebars renders unresolved variables as an empty string, not literal {{ }} text, so this went unnoticed. Fixed: template now references the snake_case globals, and both sendForMember() and run() pass currency: configService.get('CURRENCY_CODE').
Recurring entry scheduler: Runs daily at 08:00 (Redis lock), looping per active tenant via forEachActiveTenant(). For each active RecurringEntry where nextDueAt ≤ now, generates a PENDING_APPROVAL journal entry in the current month’s open accounting period and advances nextDueAt to the next due date. The journal entry creation, line saves, and nextDueAt update run against the ambient per-tenant transaction (this.txHost.tx, already holding the correct SET LOCAL search_path) rather than opening a fresh dataSource.transaction(), which would silently write to the wrong schema — each entry is still wrapped in its own Postgres SAVEPOINT/RELEASE/ROLLBACK TO SAVEPOINT so one entry’s failure rolls back in isolation instead of aborting the rest of that tenant’s batch.
Vehicle-specific asset fields: Two new optional fields added to assets table:
insurance_expiry(date) — insurance policy expiry dateroadworthiness_expiry(date) — roadworthiness certificate expiry date
Eight notification-timestamp columns track when each alert was last sent (to prevent repeat alerts on re-runs):
insurance_notified_30_days_at, insurance_notified_14_days_at, insurance_notified_7_days_at, insurance_notified_1_day_at, and the equivalent four for roadworthiness_.
Vehicle expiry alert scheduler: Runs daily at 08:00 (with distributed Redis lock). For each asset that has an insuranceExpiry or roadworthinessExpiry value, alerts are dispatched at 30, 14, 7, and 1 day(s) before expiry. Each threshold is tracked by its own timestamp column; once set it prevents a duplicate alert. Recipients: admins with ASSET_MAINTENANCE_ALERT permission. Email template: asset-vehicle-expiry-alert.html.
Permissions added:
| Permission | Scope |
|---|---|
FINANCE_APPROVE |
Approve journal entries and petty cash replenishments (cannot be the creator) |
FINANCE_RECONCILE |
Upload CSV bank statements, confirm/skip reconciliation rows, close/reopen accounting periods, reconcile offerings |
FINANCE_REPORT |
Access all 8 finance reporting endpoints |
TITHE_READ |
View individual member tithe records, giving history, annual giving statements |
TITHE_WRITE |
Manage tithe accounts and this church’s giving-checkout provider credentials |
Routes prefix (admin): /admin/finance/...
| Resource | Prefix |
|---|---|
| Funds | /admin/finance/funds |
| Giving options | /admin/finance/giving-options |
| Accounting periods | /admin/finance/accounting-periods |
| Chart of accounts | /admin/finance/accounts |
| External payees | /admin/finance/external-payees |
| Journal entries | /admin/finance/journal-entries |
| Offerings | /admin/finance/offerings |
| Budgets | /admin/finance/budgets |
| Pledge campaigns + pledges | /admin/finance/pledges |
| Pledge contribution review queue | /admin/finance/pledges/contributions |
| Recurring entries | /admin/finance/recurring-entries |
| Petty cash | /admin/finance/petty-cash |
| Reconciliation (CSV upload) | /admin/finance/reconciliation |
| Bank import profiles | /admin/finance/bank-import-profiles |
| Reports | /admin/finance/reports |
Reporting endpoints (FINANCE_REPORT required, TITHE_READ for member-giving):
| Endpoint | Description |
|---|---|
GET /admin/finance/reports/income-expense |
Income & expenditure by account, filter by periodId + fundId |
GET /admin/finance/reports/cash-flow |
Line-by-line cash movement for an account (accountId required) |
GET /admin/finance/reports/trial-balance |
All accounts with current balances. Without periodId returns currentBalance from each account row. With periodId computes period-specific balances by summing posted journal lines within that period only — accounts with no activity in the period appear with balance 0. |
GET /admin/finance/reports/fund-balance |
Per-fund total balance |
GET /admin/finance/reports/account-ledger |
Full ledger for an account with date range filter |
GET /admin/finance/reports/budget-actuals |
Budget vs actual spend (budgetId required) |
GET /admin/finance/reports/pledge-summary |
Per-pledge pledged / paid / outstanding for a campaign (campaignId required). Optional fromDate/toDate add paidInPeriod (confirmed payments dated within the range). One aggregate query; flat rows so the admin report table renders them |
GET /admin/finance/pledges/contributions/download |
FINANCE_READ. Pledge payments as Excel (pledge-payments.xlsx), filtered by optional fromDate/toDate (payment date), campaignId, status. Columns: member, email, campaign, amount, payment date, Paid Via (shared giving-checkout/util/paid-via, e.g. “Monnify · Card”), reference, status, reviewed by/at, finance note |
GET /admin/finance/reports/member-giving |
Giving history for a member (memberId required, TITHE_READ) |
GET /admin/finance/reports/dashboard |
Finance dashboard snapshot: MTD income/expenses, pending entries, budget utilisation, outstanding pledges |
cash-flow, account-ledger, member-giving default to a bounded ~365-day lookback. Omitting fromDate previously scanned every posted journal line ever recorded against the account/member — each now defaults fromDate to 365 days ago (via FinanceReportService.defaultReportFromDate()) when the caller doesn’t supply one, and echoes the effective fromDate/toDate actually used back in the response so a caller can tell a default was applied. member-giving skips the default entirely when periodId is given, since a period already bounds the query. Passing an explicit fromDate (however old) is honored as-is — the default only kicks in when both date filters are omitted.
Offering reconciliation — auto-journal (optional):
PATCH /admin/finance/offerings/:id/reconcile accepts optional fields autoJournal, debitAccountId, creditAccountId, and accountingPeriodId. When autoJournal: true all three IDs are required. A double-entry journal entry is created as PENDING_APPROVAL (not auto-posted — segregation of duties: a different admin must approve via the normal journal approval flow). Idempotency key: offering-auto-journal:{offeringId}. The reconciling admin is recorded in reconciledBy on the offering. The total equals cashAmount + expectedTransferAmount. Creation runs inside a dataSource.transaction() to prevent duplicate journals under concurrent requests. Account balances are updated only when the journal entry is subsequently approved — not at creation.
Member finance endpoints (member JWT required):
| Method | Path | Description |
|---|---|---|
GET |
/finance/giving-options |
List active GivingOptions for the online-checkout “what is this for” selector — no fund exposed |
GET |
/finance/pledge-campaigns |
List active, non-lapsed pledge campaigns a member can pledge against (member-safe subset of the admin campaign shape — no createdBy) |
POST |
/finance/me/pledges |
Self-service pledge — member commits a pledge to a campaign |
GET |
/finance/me/pledges |
List the authenticated member’s pledges |
POST |
/finance/me/pledges/:id/contributions |
Log a payment claim toward one of the member’s own pledges (amount, paymentDate, optional reference) |
GET |
/finance/me/pledges/:id/contributions |
List the contribution claims (any status) for one of the member’s own pledges |
GET |
/finance/me/giving-summary |
YTD total of all TitheRecord giving (tithes, offerings, options — ytdTithes is a legacy name), active pledges, last gift. No longer used by the member app’s Pledges tab, which summarizes pledges only from GET /finance/me/pledges |
POST |
/finance/me/giving-statement/send |
Trigger annual giving statement email for the previous year (on-demand, always available) |
Pledge campaign discovery (GET /finance/pledge-campaigns): Filters to isActive = true AND endDate >= CURRENT_DATE — a campaign that’s lapsed or been deactivated is never pledge-able even if a member still has the ID. Not paginated (bounded, admin-controlled reference data, same category as departments/venues). This is distinct from GET /admin/finance/pledges/campaigns, which is admin-only and returns the full entity including createdBy.
Deactivating a campaign (PATCH /admin/finance/pledges/campaigns/:id/active, FINANCE_WRITE): Body { isActive: boolean }. This is the only way to edit a campaign after creation — there is no general update endpoint. Deactivating a campaign only removes it from GET /finance/pledge-campaigns (members can no longer start new pledges against it); it does not touch any existing pledges under that campaign, which keep whatever status/contributions they already have.
Manual pledge completion vs. contribution-confirmed completion (important distinction): PATCH /admin/finance/pledges/:id/status and the pledge-contribution confirm flow are two independent mechanisms that can both result in status: COMPLETED, and they are not reconciled with each other by design. Manually setting a pledge to COMPLETED/CANCELLED via the status endpoint does not touch amountPaid or any pending contributions — a pledge can be manually marked COMPLETED while amountPaid is still 0 and a contribution is still sitting PENDING. The only way to reach COMPLETED with amountPaid guaranteed to equal totalAmount is via the contribution-confirm auto-complete path (PledgeService.maybeAutoCompletePledge, triggered after confirmContribution). Admins using the manual status endpoint should understand it as a pure administrative override, independent of payment tracking.
Pledge self-service: MakePledgeDto requires campaignId, totalAmount, frequency (ONE_OFF | MONTHLY | QUARTERLY), startDate. Pledges created this way are identical in schema to admin-created pledges; the audit log records source: 'member-self-service'.
Pledge status transitions: COMPLETED and CANCELLED are terminal states — once a pledge reaches either status, PATCH /admin/finance/pledges/:id/status throws 400 Bad Request. This prevents accidental reactivation of fulfilled or cancelled commitments.
Pledge reminder scheduler: Runs daily at 08:00 (Redis lock lock:pledge-reminders). For each ACTIVE pledge, calculates the next due date (rolling forward from startDate by frequency). Sends a pledge-reminder email when diffDays is 7 (upcoming), 0 (due today), or −3 (overdue). Redis cache key pledge-reminder:{pledgeId}:{dueDateKey}:{diffDays} with 2-day TTL prevents duplicate sends.
Pledge contributions (tracking actual payments): Pledge.status alone never reflected whether a pledge had actually been paid — it was a manual admin flag. finance_pledge_contributions closes that gap with a claim-and-confirm flow mirroring the tithe payment-proof pattern:
POST /finance/me/pledges/:id/contributions— the pledge’s own member logs a payment claim (amount,paymentDate, optionalreference). 403s if the pledge belongs to someone else; 400s if the pledge isn’tACTIVE. Starts asPENDING.GET /admin/finance/pledges/contributions(FINANCE_READ) — paginated review queue, filterable bystatus/pledgeId/campaignId.POST /admin/finance/pledges/contributions/:id/confirm/.../decline(FINANCE_WRITE) — finance reviews each claim. Confirming stampsreviewedBy/reviewedAt, emails the member (pledge-contribution-confirmed), and re-sums that pledge’sCONFIRMEDcontributions — if the sum reachestotalAmount, the pledge is automatically flipped toCOMPLETED(no manual status click needed). Declining requires afinanceNoteand emailspledge-contribution-declined.- Only
CONFIRMEDcontributions count.amountPaid(per pledge, onGET /finance/me/pledgesandGET /admin/finance/pledges) andtotalPaid(per campaign, on both campaign-list endpoints) are always computed live fromSUM(amount) WHERE status = 'CONFIRMED'— never stored/denormalized, same approach as the existingtotalPledged/pledgeCountsubqueries onPledgeCampaign. - A pledge’s committed
totalAmountand its actually-paidamountPaidare deliberately distinct fields — a pledge can beACTIVEwithamountPaidanywhere from 0 up to (but not yet reaching)totalAmount.
Budget utilisation alerts: Runs daily at 08:00 (Redis lock lock:budget-utilization-alerts). Calculates actuals for each active budget by summing posted journal entry lines for the budget’s account within the budget date range. Sends finance-budget-alert email to all admins with FINANCE_READ permission at 80% and 100% utilisation thresholds. Dedup via alert_80_sent_at / alert_100_sent_at columns on finance_budgets (persists across Redis flushes). Each threshold fires at most once per budget.
Finance dashboard summary (GET /admin/finance/reports/dashboard, FINANCE_REPORT permission):
Returns a point-in-time snapshot:
mtdIncome/mtdExpenses/mtdNet— month-to-date totals from posted journal linespendingJournalEntries— count of entries inPENDING_APPROVALstatuspendingPettyCash— count of replenishments inPENDINGstatusbudgetsNearLimit— all active budgets ≥ 80% utilised (sorted desc), each withname,amount,actuals,utilizationPcttotalOutstandingPledges/activePledgeCount— sum and count ofACTIVEpledgesgeneratedAt— server timestamp
Environment variables added:
| Variable | Default | Description |
|---|---|---|
ASSET_OVERDUE_NOTIFICATION_DAYS |
1,3,7 |
Comma-separated days-overdue thresholds for checkout reminders. Empty string disables. |
ANNUAL_GIVING_STATEMENT_ENABLED |
false |
Set to true to enable the Jan 1 batch annual giving statement emails to all members |
Entities: finance_funds, finance_accounting_periods, finance_accounts, finance_external_payees, finance_journal_entries, finance_journal_entry_lines, finance_journal_entry_links, finance_offerings, finance_budgets, finance_pledge_campaigns, finance_pledges, finance_recurring_entries, finance_petty_cash_replenishments, finance_bulk_upload_jobs, finance_reconciliation_rows, finance_bank_import_profiles. New FK on finance_offerings: reconciled_by_id. New FK on finance_bulk_upload_jobs: profile_id. New columns on tithe_records: source, external_reference, payment_channel; batch_id is now nullable (webhook-created records have no batch). New columns on assets: insurance_expiry, roadworthiness_expiry, plus 8 notification-timestamp columns (insurance_notified_*, roadworthiness_notified_*).
Giving Checkout’s three entities (giving_providers, tenant_giving_provider_configs, giving_checkout_sessions
— §9 Phase 9h) are deliberately not finance_*-prefixed despite living under src/giving-checkout/ — they’re
control-plane (public schema), same category as communication_providers/billing_checkout_sessions, not
tenant-schema finance_* data.
member_virtual_accounts (and tithe_records.virtual_account_id) existed here through 1784592000000-CreateMemberVirtualAccountsAndTitheSource but were dropped by tenant migration 1792108800000-DropMemberVirtualAccounts — see “Tithe virtual accounts — removed” above.
Migrations:
1783641600000-CreateFinanceFunds1783728000000-CreateFinanceAccountingPeriods1783814400000-CreateFinanceAccounts1783900800000-CreateFinanceExternalPayees1783987200000-CreateFinanceJournalEntries1784073600000-CreateFinanceOfferings1784160000000-CreateFinanceBudgets1784246400000-CreateFinancePledges1784332800000-CreateFinanceRecurringEntries1784419200000-CreateFinancePettyCash1784505600000-CreateFinanceBulkUpload1784592000000-CreateMemberVirtualAccountsAndTitheSource1784678400000-AssetVehicleFields1784764800000-AssetVehicleNotificationColumns1784851200000-TitheRecordBatchNullable1784937600000-AddTimestampsToJournalEntryLinesAndLinks1785024000000-BudgetAlertColumns1785110400000-CreateBankImportProfiles(createsfinance_bank_import_profiles+ seeds canonical default profile)1785196800000-BulkUploadJobProfileFK(addsprofile_idnullable FK tofinance_bulk_upload_jobs)1785283200000-OfferingReconciledBy(addsreconciled_by_idnullable FK tofinance_offerings)
Finance Request Module
Manages expense requests raised by department heads (HODs) through a finance team review lifecycle.
Lifecycle: PENDING → APPROVED / REJECTED. On approval, the finance team attaches proof of payment via a
separate PATCH /:id/proof endpoint.
Self-approve guard: An admin cannot approve a request they submitted. Returns 403 Forbidden.
Proof replacement: If PATCH /:id/proof is called on a request that already has a proof file, the old Cloudinary asset is deleted before uploading the new one. If the delete fails (network error, already removed), the error is logged and the upload proceeds anyway — the old asset may be orphaned but the request is not blocked.
Posting to the ledger (PATCH /:id/proof with postToJournal: true): optional — proof-attachment time, not
approve/reject, is when this is offered, since that’s when there’s actual evidence money moved (reject never moves
money; approval alone doesn’t either). When set, the caller also sends debitAccountId (an active EXPENSE
account) and creditAccountId (the account paid from) in the same multipart body. Mirrors
PettyCashReplenishment’s approve-time posting exactly: creates one PENDING_APPROVAL JournalEntry with two
JournalEntryLines (DEBIT the expense account, CREDIT the paying account, both for request.amount) and a
JournalEntryLink (linkType: FINANCE_REQUEST, role: RECIPIENT, financeRequestId) back to the request, guarded
by idempotency key finance-request:{id} — a repeat call with postToJournal: true on an already-posted request
just re-links the existing entry rather than erroring or duplicating it. FinanceRequest.journalEntry is set to the
created entry. A second, different admin still has to approve the entry via the normal Journal Entries flow
(PATCH /admin/finance/journal-entries/:id/approve) before it posts to Account.currentBalance — segregation of
duties is unchanged. Requires an OPEN AccountingPeriod for the current month (400 otherwise) and the tenant’s
plan to include PlanFeature.FINANCE (403 PLAN_UPGRADE_REQUIRED otherwise) — proof upload without posting stays
available regardless of plan, since FinanceAdminController itself carries no PlanGuard.
Email notifications:
- On creation → all active admins with
FINANCE_WRITEpermission are notified (filtered in SQL viaANY(r.permissions)) - On approve/reject/proof → the HOD who raised the request is notified
HOD enforcement: Only workers with a lead assignment (DepartmentLead record) can create or view department
requests. A worker can only raise a request for their own department (verified server-side).
Paid (derived, not stored): the stored status stays APPROVED after the finance team attaches the payment proof
(PATCH /admin/finance/requests/:id/proof). Every loaded FinanceRequest carries a computed isPaid
(status === APPROVED && proofUrl, set in an @AfterLoad hook, and on the response of the proof upload). The admin
portal and the HOD’s member-app list show “Paid” when it’s true; the HOD also gets a “View payment proof” link and, on
rejected requests, the rejection reason. The Excel export’s Status column reads PAID for these rows. Budget and
report figures that count APPROVED requests are unaffected.
Admin list filters: GET /admin/finance/requests now accepts additional query params for richer filtering:
| Param | Type | Description |
|---|---|---|
status |
enum | PENDING | APPROVED | REJECTED, or AWAITING_PAYMENT (approved, no payment proof yet) / PAID (approved with a payment proof). APPROVED still means every approved request. Unknown values → 400 |
categoryId |
UUID | Filter to a specific expense category |
memberId |
UUID | Filter to requests raised by a specific member |
departmentId |
UUID | Filter to requests raised by a specific department |
search |
string | Wildcard match on requester name, email, or reason text |
page / limit |
int | Pagination (default 1 / 20) |
GET /admin/finance/requests/download accepts the same filters (no pagination) and returns an .xlsx file with columns: Requester, Email, Department, Category, Amount (NGN), Status, Reason, Reviewed By, Reviewed At, Rejection Reason.
Routes prefix (admin): /admin/finance
Routes prefix (worker): /finance
Follow-Up Module
Handles first-timer registration, follow-up task management, and post-event engagement workflows.
First-timer registration is available on both the worker mobile app (workers in the FOLLOW_UP department) and the admin portal (admins with FOLLOW_UP_WRITE). On creation, a FollowUpTask of type FIRST_TIMER is automatically created and assigned via round-robin to the FOLLOW_UP-department worker with the fewest open tasks. The pick and task creation run inside a single transaction protected by a PostgreSQL advisory lock (pg_advisory_xact_lock(hashtext('follow-up:round-robin'))), serializing concurrent registrations so the open-task count is always accurate.
When no active FOLLOW_UP worker exists to assign, this is not an error — doCreateFirstTimer() still creates the FirstTimer and its FollowUpTask with assignedTo: null (nullable since migration MakeFollowUpTaskAssignedToNullable; was NOT NULL originally, which would have hard-blocked creation). The returned FirstTimer carries a transient assignmentWarning: string | null (not persisted — same pattern as visitCount) so callers can surface a warning instead of silently losing the registration. The admin UI shows it as a dismissible banner instead of auto-closing the “Add First Timer” panel. An unassigned task shows up in GET /admin/follow-up/tasks like any other (with assignedTo: null, “—” for worker name) and can be handed to someone once a worker becomes active via PATCH /admin/follow-up/tasks/:id/reassign.
Self-onboarding from the member app’s signup screen (POST /follow-up/public/first-timer, FollowUpPublicController): @Public(), no login required — reached from discuva-member’s own signup page by someone who just installed the app and isn’t (yet, or ever) ready to create a full member account, so they can still let the church know they’re here. Mirrors FormPublicController’s shape (module gate via ModuleEnabledGuard, rate-limited @Throttle({limit: 5, ttl: 60_000}) since it’s an open unauthenticated write). Calls FollowUpService.createFirstTimerFromAppSignup(), which — like createFirstTimerFromPublicForm forcing ONLINE — forces source to a new FirstTimerSourceEnum.APP_SIGNUP value regardless of what the caller submits, and passes an empty actor (no createdByMember/createdByAdmin). Goes through the exact same doCreateFirstTimer path as every other first-timer creation route: round-robin FollowUpTask assignment, due date, fire-and-forget assignment email. Returns only { received: true, firstTimerId } — never the assignee or other internal details, to an unauthenticated caller.
“Which event is this for?” picker (GET /follow-up/public/events, FollowUpPublicController.events; and the existing authenticated GET /events?from=&to= for admin/worker callers): Backs the event-visited field on both the unauthenticated member-app self-onboarding form above and the admin’s manual “Add First Timer”/“Log Visit” forms. FollowUpService.getPublicEvents(search?) defaults to today’s events plus the last 14 days (event.eventDate <= today AND event.endDate >= today − 14d, never future events, newest first, max 10; “today” is computed in CHURCH_TIMEZONE — both event_date and end_date are indexed, migration AddEventEndDateIndex) so a visitor can tap the service they attended instead of typing — a name search (?search=, also past/today only) is the fallback for older services. Each option returns { id, name, eventDate, endDate, isToday } (dates as YYYY-MM-DD); the member app shows “Today” or the weekday + date (a range for multi-day events) beside each name. Public variant is @Public() + ModuleEnabledGuard (module follow_up) + @Throttle({limit: 30, ttl: 60_000}) (read-only, higher limit than the write endpoint above since it’s typeahead-driven), and selects only id/name/eventDate/endDate — no attendance, slot, or venue detail, since the caller isn’t authenticated. The admin UI instead reuses the existing authenticated GET /events route with from/to set to today for the same “today” default, since an admin session already has full read access to that endpoint.
Editing a first-timer’s own details (PATCH /admin/follow-up/first-timers/:id, admin; PATCH /follow-up/first-timers/:id, worker; both UpdateFirstTimerDto): for correcting a record after the fact — e.g. an admin forgot to set the event visited, or a Sunday School check-in only captured partial info. All fields optional/independent (firstname, lastname, phone, email, wantsToJoinChurch, wantsToJoinWorkforce, enjoyedAboutChurch, notes, visitedEventId); source is deliberately not editable here — it’s forced server-side at creation to stay non-spoofable, and changing it after the fact would corrupt source attribution in reports. convertedAt/inviteSentAt have their own dedicated endpoints. FollowUpService.updateFirstTimer() reloads the record with its visitedEvent relation before returning, so the response reflects the current event name, not just the id that was set. The worker variant (updateFirstTimerByWorker) is a thin wrapper adding assertWorkerInFollowUpDept first — same shape as getFirstTimerDetailForWorker — and isn’t scoped to only first-timers on the caller’s own tasks, matching createFirstTimerByWorker’s existing department-wide (not just own-task) access. Backs an inline “Edit Details” toggle on the member app’s task detail screen, using the same public today-first event picker (GET /follow-up/public/events) as the self-onboarding form, since it’s @Public() and works fine from an authenticated session too.
First-timer visit history (GET /admin/follow-up/first-timers/:id, admin; GET /follow-up/first-timers/:id, worker): Returns { firstTimer, visitCount, timeline, convertMatches, linkedConvert } — FollowUpService.getFirstTimerDetail() (worker variant wraps it with assertWorkerInFollowUpDept first). timeline merges three sources into one dated list, each entry { source: 'INITIAL_VISIT' | 'LOGGED_VISIT' | 'SUNDAY_SCHOOL' | 'OUTREACH_MET' | 'EVANGELISM_FOLLOW_UP', label, occurredAt, notes? }: the first-timer’s own createdAt as INITIAL_VISIT; each FirstTimerVisit row as LOGGED_VISIT; and each SundaySchoolAttendance row linked via first_timer_id as SUNDAY_SCHOOL (queried directly — SundaySchoolAttendance is registered read-only in FollowUpModule rather than importing SundaySchoolModule, which would be circular since it already imports FollowUpModule). visitCount counts only the three visit sources — the outreach entries (see “Outreach convert → first-timer” below) are history, not visits. The admin route is declared after first-timers/pipeline in FollowUpAdminController so that static path keeps matching first. getFirstTimers (the list endpoint) carries a lighter version of the same idea: loadRelationCountAndMap('ft.visitCount', 'ft.visits') for the logged-visit count, then one batched SundaySchoolAttendance count query (first_timer_id IN (:...ids), grouped) across the whole page — never per-row — plus +1 per row for the initial visit.
Post-event jobs (Bull queue follow-up):
- After
markAbsentees()completes for an event, apost-eventBull job is dispatched. PostEventProcessor.handlePostEventsends thank-you emails to all PRESENT/LATE members ifevent.thankYouSentAtis null, then setsthankYouSentAt— preventing duplicate sends on re-trigger.- If
event.onlineAttendanceEnabled = true: sends online-confirm request emails to ABSENT members (button links to the service’s page in the church’s member app —resolveMemberUrl('/events/:id'); a signed-out member is returned there after sign-in), setsevent.onlineNotificationSentAtandevent.onlineConfirmClosesAt, and schedules aonline-window-closeddelayed job (ONLINE_CHECKIN_WINDOW_HOURShours later, default 3). handleOnlineWindowClosedcreatesONLINE_NO_RESPONSEfollow-up tasks for all members still marked ABSENT.
Online confirm flow:
Members receive an email after an online-attendance-enabled event. They confirm via POST /attendances/online-confirm { eventId }. The system:
- Checks
event.onlineAttendanceEnabled = true - Validates that
now ≤ onlineConfirmClosesAt(set alongsideonlineNotificationSentAtwhen the emails go out, from the church’s window —church_settingskeyattendance:online_confirm_window_minutes, managed atGET/PATCH /attendances/settings/online-window, else envONLINE_CHECKIN_WINDOW_HOURS; events from before the column existed fall back toonlineNotificationSentAt + current window) - Finds the ABSENT record for
(member, event)and updates status toATTENDED_ONLINE
Task assignment email: When a FollowUpTask is created (first-timer registration or online non-responder) or reassigned, an email is sent to the assigned worker using the follow-up-task-assigned template. Includes the first-timer’s name, phone, email, and due date. Fire-and-forget via the email Bull queue.
Overdue escalation (daily cron at 08:00): FollowUpScheduler.escalateOverdueTasks runs every day at 08:00. It finds all tasks with status PENDING or IN_PROGRESS where dueDate < NOW(). Each affected worker receives a digest email (follow-up-overdue-worker) listing all their overdue contacts. All active admins with FOLLOW_UP_WRITE permission receive a summary count email (follow-up-overdue-admin).
Inactive task detection (daily cron at 09:00): FollowUpScheduler.notifyInactiveTasks runs every day at 09:00. It finds open tasks whose lastActivityAt < NOW() - FOLLOW_UP_STALE_DAYS (default 7 days). All active admins with FOLLOW_UP_WRITE permission receive a count email (follow-up-stale-admin). GET /admin/follow-up/tasks/stale also exposes this list on demand.
Due date: Tasks auto-set dueDate = createdAt + FOLLOW_UP_DUE_DAYS (default 3 days).
Pastoral report: GET /admin/follow-up/report?from=&to= (requires FOLLOW_UP_READ) returns aggregate stats: first-timer totals, source breakdown, wants-to-join counts, task status/outcome breakdown, overdue snapshot, conversion rate, per-worker performance, and per-event first-timer counts. Date range is optional; omitting it returns all-time stats.
Membership invitation: POST /admin/follow-up/first-timers/:id/invite-to-membership (requires FOLLOW_UP_WRITE) queues a personalised invitation email to the first-timer. Returns { queued: true } on success or { queued: false } if the invitation was already sent (inviteSentAt is set). Throws 404 if the first-timer is not found or 400 if no email address is on record. Sets FirstTimer.inviteSentAt on first send to prevent duplicate emails.
First-timer conversion: PATCH /admin/follow-up/first-timers/:id/mark-converted (requires FOLLOW_UP_WRITE) marks a first-timer as having joined the congregation. Accepts an optional memberId (UUID) body field to link the first-timer to their new Member record. Sets FirstTimer.convertedAt and optionally FirstTimer.convertedMember. When a memberId is given and an outreach convert is linked to this first-timer, that convert is marked joined too (member/linkedAt, audit CONVERT_LINKED_TO_MEMBER with metadata.via = 'first_timer') — the church records joining once. The reverse also holds: Evangelism’s PATCH evangelism/converts/admin/:id/link-member sets convertedAt/convertedMember on the linked first-timer if it isn’t converted yet.
Outreach convert → first-timer (FirstTimerConvertService): someone met on outreach (an evangelism Convert) who later visits is registered as a new FirstTimer; Follow-Up confirms whether the two are the same person — nothing links automatically.
- Suggestions —
convertMatcheson the first-timer detail: up to 5 converts not yet linked to a first-timer or member, not infirst_timers.dismissed_convert_ids, whose E.164phoneequals the first-timer’s, or (when the convert has no phone) whosenameequalsfirstname lastnamecase-insensitively; each carriesmatchedOn: 'phone' | 'name'. The list endpoint addshasConvertMatchper row from one batched query for the page. - Confirm —
POST /follow-up/first-timers/:id/link-convert(Follow-Up dept worker) /POST /admin/follow-up/first-timers/:id/link-convert(FOLLOW_UP_WRITE), body{ convertId }.409if the convert is already linked or already a member, or the first-timer already has a convert (converts.first_timer_idis unique). Setsconverts.first_timer_id/first_timer_linked_at, clears the convert’sassignedTo(theFollowUpTasknow owns the follow-up), logs aConvertFollowUpLog“Visited church as a first-timer…”, auditsCONVERT_LINKED_TO_FIRST_TIMER, flushes both report caches and pushesCONVERT_VISITED_CHURCHto the convert’s previous assignee, onboarder and outreach team (not the actor). - Dismiss —
POST …/first-timers/:id/dismiss-convert{ convertId }appends todismissed_convert_idsso it isn’t suggested again. - Unlink —
DELETE …/first-timers/:id/link-convertundoes a wrong match; the convert returns to Evangelism unassigned. AuditCONVERT_UNLINKED_FROM_FIRST_TIMER. - Timeline — once linked, the first-timer detail
timelinestarts withOUTREACH_MET(outreach title, team innotes, at the convert’screatedAt) and oneEVANGELISM_FOLLOW_UPper evangelism contact logged before the hand-over. Convert,ConvertFollowUpLogandMemberare registered inFollowUpModuledirectly — importingEvangelismModulewould be circular (Evangelism → Member → FollowUp).
Admin task update: PATCH /admin/follow-up/tasks/:id (requires FOLLOW_UP_WRITE) lets an admin update any task’s status, outcome, outcomeNotes, dueDate, and add a noteContent (with optional contactMethod) note. Unlike the worker endpoint, this is not restricted by assignment. Also sets lastActivityAt.
Worker standalone note: POST /follow-up/tasks/:id/notes (FOLLOW_UP dept worker) adds a note with an optional contactMethod (PHONE_CALL | WHATSAPP | IN_PERSON | SMS | EMAIL) without requiring a status change. Updates lastActivityAt.
Return visit tracking: POST /admin/follow-up/first-timers/:id/visits (requires FOLLOW_UP_WRITE) records that a first-timer attended again. Body: { eventId?, notes?, visitedAt? } — visitedAt defaults to today.
No dedicated first-timer SMS route. Texting first-timers is done by adding them to a Group (see Groups Module’s phone-only entries, sourced “from First-Timers” over a date range) and sending via POST /announcements/sms-broadcast with audience: GROUP — this superseded a former one-off POST /admin/follow-up/first-timers/sms route, consolidating all SMS sending into the Announcements module.
Pipeline report: GET /admin/follow-up/first-timers/pipeline?from=&to= (requires FOLLOW_UP_READ) returns a funnel breakdown: { total, untouched, contacted, returned, invited, converted }. Each first-timer is placed in the highest stage they have reached.
Stale task list: GET /admin/follow-up/tasks/stale?daysInactive=7&page=1&limit=20 (requires FOLLOW_UP_READ) returns open tasks with no activity for ≥ N days, ordered oldest-activity-first.
Reassigning a task (PATCH /admin/follow-up/tasks/:id/reassign, { workerProfileId }): for when the currently-assigned worker leaves, goes inactive, or the round-robin pick just isn’t right — moves a task to a different worker. FollowUpService.reassignTask() requires the target to both have the MANAGE_FOLLOW_UP capability (primary or secondary department) and be ACTIVE — the same two conditions pickRoundRobinAssignee enforces for automatic assignment, so a manual reassign can’t put a task on someone the round-robin logic itself would never pick. GET /admin/follow-up/workers (also FOLLOW_UP_READ, not DEPARTMENTS_READ, so a Follow-Up-only admin doesn’t need department access to use it) backs the picker for this — returns the same active/capability-filtered worker list, unpaginated (small team).
Routes (worker mobile): /follow-up/first-timers, /follow-up/tasks/mine, /follow-up/tasks/:id, /follow-up/tasks/:id/notes
First-timer list filtering: GET /admin/follow-up/first-timers accepts optional dateFrom and dateTo (YYYY-MM-DD) to restrict results to first-timers registered within that date range. Both are optional; omitting either removes the respective bound.
Routes (admin portal): /admin/follow-up/first-timers, /admin/follow-up/first-timers/pipeline, /admin/follow-up/first-timers/:id/invite-to-membership, /admin/follow-up/first-timers/:id/mark-converted, /admin/follow-up/first-timers/:id/visits, /admin/follow-up/tasks, /admin/follow-up/tasks/:id, /admin/follow-up/tasks/stale, /admin/follow-up/tasks/:id/reassign, /admin/follow-up/tasks/bulk, /admin/follow-up/report, /admin/follow-up/workers
Evangelism Module
Tracks converts from initial outreach contact through to becoming a church member — distinct from the Follow-Up
module above, which is scoped to first-timers who visited a service. A convert here is not assumed to be an
existing Member; they may just be a name and phone number an outreach worker captured in the field.
Entities:
Outreach(outreaches) — one outing:title/location(nullable),outreachDate(date, defaults to the church’s today viaDateService.today()),createdBy(SET NULL; also exposed ascreatedById) /createdByName(snapshot), andteam(ManyToMany →Memberviaoutreach_team(outreach_id, member_id), both CASCADE). Evangelism is usually done in twos or threes, so the team is recorded once per outing rather than re-tagged on every convert. The creator is always on the team and can’t be removed from it.Convert(converts) —name,phone(nullable, E.164, indexed for the duplicate check),notes(nullable),status(UNSAVED|SAVED|UNDERGOING_DISCIPLESHIP, defaultUNSAVED),onboardedBy/onboardedByName(who added them, snapshotted),outreach(nullable, SET NULL — null means the adder went alone),assignedTo(ManyToOne →WorkerProfile, nullable SET NULL — who owns the follow-up),member/linkedAt(set once the convert becomes an actualMember, mirrorsfirst_timers.converted_member_id/converted_at),firstTimer/firstTimerLinkedAt(set when Follow-Up confirms the convert visited church — see the Follow-Up module’s “Outreach convert → first-timer”; unique, SET NULL),lastContactedAt(denormalized, updated on every new follow-up log).ConvertFollowUpLog(convert_follow_up_logs) — one row per contact attempt:convert(CASCADE),loggedBy/loggedByName,note(nullable),contactedAt— mirrors theFirstTimerVisitidiom.
A convert’s outreach team is its outreach’s team plus onboardedBy.
Journey stage (stage on every list row): JOINED (linked to a member) → WITH_FOLLOW_UP (visited church;
Follow-Up owns the follow-up) → FOLLOWED_UP (any contact logged) → MET. Once WITH_FOLLOW_UP, the convert is
read-only for Evangelism: follow-up logging and status changes return 409
(assertCanActOnConvert(…, { write: true }); history uses write: false), admin reassign returns 409, bulk
reassign skips it, isOverdue is false, and it’s excluded from every “open” count (member_id IS NULL AND first_timer_id IS NULL): the overdue filter, auto-assign round-robin load, searchWorkers.openAssigned, bulk
“move all open”, and the report’s needsFollowUp/unassigned/assignedOpen. List filter
stage=open|with_follow_up|joined. The converts CSV gains a Stage column.
Settings (EvangelismSettingsService, stored as the church_settings row evangelism:settings — same
pattern as SundaySchoolSettingsService, cached 5 min):
overdueDays(1–90, default 7) — a convert not yet linked to a member and not contacted for longer than this (or never) “needs follow-up”. Used by theisOverdueflag, theoverduelist filter and the report.autoAssign(defaulttrue) — off leaves new converts unassigned for an admin to assign.
Access model:
- Adding a convert / starting an outreach — any
WORKER(RolesGuard). Onlynameis required for a convert. - Acting on a convert (
POST :id/follow-up,PATCH :id/status,GET :id/follow-up-history) —ConvertService.assertCanActOnConvert(): the onboarder, anyone on its outreach team, the assignee, or a worker whose primary or secondary department hasMANAGE_EVANGELISM_CONVERTS. Anyone else gets403. - Listing —
GET evangelism/converts?scope=mine(default) is open to any worker and returns converts where the caller is the onboarder, on the outreach team, or the assignee.scope=teamreturns every convert and requires theMANAGE_EVANGELISM_CONVERTScapability. Each row carriesmyRoles(onboarder|team|assignee) for the caller. This replaces the oldGET evangelism/converts/team. - Admin portal —
AdminGuard+EVANGELISM_READ/EVANGELISM_WRITE. Admins can assign to any active worker (WorkerProfileandMemberbothACTIVE, else400), not only the Evangelism department.
Auto-assignment on create (ConvertService.pickAssignee, skipped when autoAssign is off): the adder if
they have the capability, else the first outreach teammate who does, else round-robin to the active
capability worker with the fewest open (not-yet-linked) converts — the same shape as
FollowUpService.pickRoundRobinAssignee. No candidate → unassigned.
Duplicate check: when phone is given and allowDuplicate isn’t true, an existing convert with the same
E.164 phone returns 409 { code: 'CONVERT_DUPLICATE', existing: { id, name, onboardedByName, createdAt } }. The
client then offers POST evangelism/converts/:id/met-again (any worker; logs a follow-up prefixed “Met again”)
or a retry with allowDuplicate: true.
List filters (shared by the member list, the admin list and the converts export via
ConvertService.buildConvertQuery): status, assignedTo (workerProfileId | unassigned | me — me
is member-only, 400 in the admin portal), overdue=true, outreachId, search (name or phone, ILIKE),
from/to (created date), page/limit (max 100). Ids are paged with DISTINCT first, then loaded with
relations, because the outreach-team join is many-to-many. Team members are returned as
{ id, firstname, lastname } only.
Admin management:
PATCH converts/admin/:id/reassign{ workerProfileId },PATCH converts/admin/:id/unassign.PATCH converts/admin/bulk-reassign{ toWorkerProfileId, convertIds? (≤500) | fromWorkerProfileId? }— exactly one source;fromWorkerProfileIdmoves all of that worker’s open converts (e.g. when they step down). OneUPDATE; returns{ updated }.PATCH converts/admin/:id/outreach{ outreachId | null }— re-file or detach a convert.PATCH outreaches/admin/:id/team{ teamMemberIds }— fix an outreach team (members can do the same from the app viaPATCH outreaches/:id/teamif they’re on it).GET outreaches/admin?from=&to=— up to 200 outreaches with their teams, for filters and pickers.GET converts/admin/workers?q=/GET evangelism/workers?q=— oneOutreachService.searchWorkersmethod behind both: up to 20 active workers as{ memberId, workerProfileId, firstname, lastname, isEvangelism, openAssigned }, Evangelism workers first; the member route excludes the caller.
Report (GET evangelism/report?from=&to=, EVANGELISM_READ; default last 90 days; cached 5 min under
evangelism:report:*, flushed on every convert/outreach/follow-up write):
summary—added,byStatus(of converts added in range),visitedChurch(first_timer_linked_atin range),joinedChurch(linked_atin range),followUpsLogged,outreaches(in range);needsFollowUpandunassignedare current, not range-bound.trend—[{ period, added, visited, joined }], weekly buckets up to 26 weeks, monthly beyond.byWorker—outreaches(team memberships in range),broughtIn(converts in range where they were the adder or on the team, counted once),joinedChurch(of those, now linked),followUpsLogged(in range);assignedOpen/overdueare current.byOutreach— date, title, location,teamSize,converts,saved,discipleship,visitedChurch(linked to a first-timer or already a member),joinedChurch.
Export (GET evangelism/export?type=converts|workers|outreaches&…, EVANGELISM_READ +
@RequiresPlan(BULK_EXPORT)): one CSV route rather than a format flag per endpoint, because PlanGuard gates
per route. converts takes the list filters (unpaged, capped at 5,000 rows); workers/outreaches take
from/to and reuse the report rows. Cells are quoted; free text starting with = + - @ (other than a plain
number such as an E.164 phone) is prefixed with ' to stop spreadsheet formula injection.
Notifications — push only, category EVANGELISM (EMAIL_EVANGELISM_ENABLED, default true; per-church
switch in notification settings), never sent to the actor:
OUTREACH_TEAM_ADDED— to members added when an outreach is created or its team is edited (only newly added).CONVERT_ASSIGNED— to the assignee on auto-assign (unless they added it) and on admin reassign.CONVERTS_BULK_ASSIGNED— one push with the count to the target of a bulk reassign.CONVERT_VISITED_CHURCH— to the previous assignee, onboarder and outreach team when Follow-Up confirms the convert came to church.
Audit actions: CONVERT_CREATED (metadata outreachId, assignedTo), CONVERT_STATUS_UPDATED,
CONVERT_FOLLOW_UP_LOGGED, CONVERT_REASSIGNED, CONVERT_UNASSIGNED, CONVERTS_BULK_REASSIGNED,
CONVERT_OUTREACH_CHANGED, CONVERT_LINKED_TO_MEMBER, OUTREACH_CREATED, OUTREACH_TEAM_UPDATED,
EVANGELISM_SETTINGS_UPDATED, plus CONVERT_LINKED_TO_FIRST_TIMER/CONVERT_UNLINKED_FROM_FIRST_TIMER from
Follow-Up. Admin actions log the admin’s member id as the actor.
Routes (member app, workers): POST evangelism/converts, POST evangelism/converts/:id/met-again,
GET evangelism/converts?scope=&status=&assignedTo=&overdue=&outreachId=&search=&from=&to=&page=&limit=,
POST evangelism/converts/:id/follow-up, PATCH evangelism/converts/:id/status,
GET evangelism/converts/:id/follow-up-history?page=&limit=, GET evangelism/workers?q=,
POST evangelism/outreaches, GET evangelism/outreaches/recent (outreaches the caller is on, last 14 days —
the app pre-selects today’s so teammates on different phones add into the same outreach),
PATCH evangelism/outreaches/:id/team
Routes (admin portal): GET evangelism/converts/admin?…filters…, GET evangelism/converts/admin/workers?q=,
PATCH evangelism/converts/admin/bulk-reassign, PATCH evangelism/converts/admin/:id/reassign,
PATCH evangelism/converts/admin/:id/unassign, PATCH evangelism/converts/admin/:id/outreach,
PATCH evangelism/converts/admin/:id/link-member, GET evangelism/converts/admin/:id/follow-up-history,
GET evangelism/outreaches/admin?from=&to=, PATCH evangelism/outreaches/admin/:id/team,
GET|PATCH evangelism/settings/admin, GET evangelism/report?from=&to=, GET evangelism/export?type=…
Sermon Module
A link-based sermon archive — no file uploads. Each Sermon (title, speakerName, date, optional description,
series, youtubeUrl, mixlrUrl) stores links to where the content actually lives (YouTube, Mixlr) rather than
hosting media itself. At least one of youtubeUrl/mixlrUrl is required on create, and an update that would clear
both (leaving neither set) is rejected with 400 — a sermon archive entry with no link anywhere is not useful.
series is a plain string tag, not its own entity — deliberately, since nothing in this feature needs series-level
metadata beyond a filterable label. Paginated (grows unboundedly, same policy as members/attendance).
“Announce Live” manual trigger (POST admin/sermons/announce-live): the MVP for “auto-trigger an announcement
when we go live” — an admin clicks “We’re Live on YouTube” or “We’re Live on Mixlr” on the Sermons page, providing
the livestream URL. This calls AnnouncementService.createSystemAnnouncement() directly (see Announcements Module)
with a default title (🔴 Live Now on YouTube / 🔴 Live Now on Mixlr, overridable) and a body containing the URL.
Zero external-API risk, works identically for both platforms today, and doubles as the fallback path for the planned
YouTube WebSub automation (a channel actually going live still needs a human-clickable escape hatch for when the
automated detection misses a stream or a platform’s API is unavailable).
Routes (admin, AdminGuard): POST/GET/PATCH/DELETE admin/sermons(/:id for single-record routes) —
SERMON_READ/SERMON_WRITE; POST admin/sermons/announce-live — SERMON_WRITE.
Routes (member, JwtAuthGuard + @RequiresModule('sermons')): GET sermons?page=&limit=&series=,
GET sermons/:id — any authenticated member/worker, no department or class gating (sermons are for everyone).
Sermon notes: now stored in the Notes Module (notes table). The original GET/PUT/DELETE sermons/:id/note
routes still work for older app versions and are backed by NotesService (plain text in, plain text out; the latest
note linked to that sermon). The sermon_notes table was copied into notes by the tenant migration CreateNotes
and then dropped by DropSermonNotes, which refuses to drop (failing the deploy, nothing lost) if any old note is
missing from notes, and only touches the church’s own schema (the legacy public copy is left alone). Its down()
recreates the table from each member’s latest sermon note.
Notes Module (src/notes/)
Private notes members write in the member app: sermon notes taken during a service, personal notes and Bible-study
notes. Module key notes (in KNOWN_MODULES, and added to every plan’s features by the root migration
AddNotesToPlans), so ModuleEnabledGuard checks both the church toggle and the plan.
Privacy: a note is only ever returned to the member who wrote it — every query is scoped by member_id and a
note owned by someone else is a 404. No admin route returns note content; GET admin/notes/insights returns totals only.
Content: the editor’s (Tiptap/ProseMirror) JSON document, validated as { type: 'doc' } and capped at 200 KB.
On every save the server derives, never trusting the client:
plain_text— for search and list excerpts;scripture_refs— canonical refs (JHN.3.16,JHN.3.16-18,PSA.23; USFM book codes) fromscripturenodes;commitment— the text under the guided template’s heading withattrs.promptId = 'action'(“One thing I’ll do this week”), up to 200 characters, used for the Monday reminder;word_count— words outside headings, so an untouched template is 0. The streak, the evening reminder and the admin totals only count notes withword_count > 0. Added byAddNoteWordCount, which also back-fills existing notes (with its own frozen copy of the counting rule).
The member app keeps a new note on the phone until something is written in it, so empty notes aren’t created.
Notes for a service: POST notes with serviceSlotId links the note to that slot and its event, titles it after
the event and defaults kind to sermon. A partial unique index (UQ_notes_member_service_slot) allows one note
per member per service, so starting notes again (or two taps at once) returns the existing note instead of a second.
Linking a service: PATCH notes/:id { serviceSlotId } links a note to a service (sets service_slot_id and
event_id); null unlinks. Linking a service that already has another of the member’s notes fails with
409 { code: 'NOTE_SERVICE_TAKEN', noteId }. GET notes/services lists services from the last 35 days
(LINKABLE_SERVICE_DAYS) the member can link to — EVERYONE events plus any they attended — with attended and
the member’s existing noteId for each. GET notes/:id and PATCH notes/:id return the note with
service: { serviceSlotId, serviceName, eventId, eventName, startTime } | null and
sermon: { id, title, speakerName, date } | null.
Linking a sermon (optional, by the member): PATCH notes/:id { sermonId } (null unlinks). It doesn’t change
the note’s kind, and nothing links a sermon automatically — the member app suggests sermons dated the same day as
the note’s service first. Linked notes are listed on the sermon’s page (GET notes?sermonId=).
Edit conflicts: PATCH notes/:id accepts baseUpdatedAt (the updatedAt the client last saw). If the stored
note is newer and the request changes the title or content, it fails with 409 { code: 'NOTE_CONFLICT', note } so
the app can keep both versions (it saves the phone’s copy as a separate note). Pinning skips the check.
Context (GET notes/context): the service slot happening now, or the most recent one today
(start ≤ now + 30 min and end ≥ now − 12 h), preferring the slot the member checked in to, then a live slot. Events
with a non-EVERYONE audience only count if the member has an attendance record. Includes the programme’s first
SPEAKER slot (member name or guest name, and topic), a sermon dated the same local day, and the member’s existing
note for that slot. null when nothing matches.
Streak (GET notes/streak): consecutive weeks (Monday-start, church timezone) with at least one sermon note.
This week counts as pending, so a streak only breaks after a full missed week. Returns { current, best, thisWeek }.
Weeks are cached per member (1 h) and cleared on create/delete. Not shown on any leaderboard.
Most noted (GET notes/top-scriptures?eventId=): up to 5 refs noted by the most members for that event, only
when at least 3 different members noted a ref (TOP_SCRIPTURE_MIN_MEMBERS), so it never points at one person.
Cached 10 min.
Bible version taps (POST notes/scripture-taps): the member app shows KJV and BSB (public domain, bundled in the
app) and opens copyrighted versions on bible.com. It batches taps on those links as { taps: [{ version, count }] };
they are summed per day and version in scripture_link_taps to help a church judge whether a licence is worth it.
Reminders (NoteNudgeScheduler): @Cron('5 * * * *'), Redis lock lock:note-nudges (900 s). Uses
SchedulerGateService.activeTenants() (cached, now including timezone) and only opens a tenant transaction when
that church’s local hour matches, and only if the Notes module is on for the church and its plan:
- 19:00 local: members who attended (
PRESENT,LATE,ATTENDED_ONLINE) an event that ended in the last 14 h and have no note for it getNOTE_EVENING_NUDGE, one push per service, linking to/notes/new?slot=…(idempotency keynote-evening:{eventId}). - Monday 08:00 local: each member’s latest
commitmentfrom the past 8 days, asNOTE_COMMITMENT_REMINDER(shortened to 90 characters), linking to the note (idempotency keynote-commitment:{noteId}).
Both are in the NOTES push category (EMAIL_NOTES_ENABLED, default true; per-church switch in Notification
Settings). Members can opt out themselves with PUT notes/preferences { nudges: false } (members.note_nudges).
Routes (member, JwtAuthGuard + @RequiresModule('notes')): GET notes?page=&limit=&kind=&q=&sermonId=
(paginated summaries, pinned first then newest), GET notes/context, GET notes/streak,
GET notes/top-scriptures?eventId=, POST notes/scripture-taps, GET/PUT notes/preferences, GET notes/services, GET notes/:id,
POST notes, PATCH notes/:id, DELETE notes/:id.
Routes (admin, AdminGuard + @RequiresModule('notes')): GET admin/notes/insights — SERMON_READ; returns
{ notesLast30Days, membersLast30Days, scriptureTaps: [{ version, count }] } (taps over 90 days). Shown as a card
on the admin Sermons page.
YouTube Live Detection (src/integrations/youtube/)
Automated follow-up to the Sermon Module’s manual “Announce Live” trigger — detects when a tenant’s configured
YouTube channel goes live and calls AnnouncementService.createSystemAnnouncement() automatically, no admin click
needed. Per-tenant BYOK, redesigned from an earlier single-global-channel version (docs/MULTI_TENANT_MIGRATION.md
§9 Phase 8b) — every tenant sets their own channel (and optionally their own Data API key) via
PUT /v1/youtube-integration; there is no platform-wide default channel.
Entity — TenantYoutubeIntegration (tenant_youtube_integrations, public schema, not tenant-schema):
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| tenantId | UUID, unique | FK → tenants.id, CASCADE — one integration per tenant |
| channelId | varchar, unique | The tenant’s YouTube channel id |
| apiKeyEncrypted | varchar | null, select: false |
AES-256-GCM (EncryptionService); null means live-detection is a no-op until the tenant sets one — no platform-wide fallback |
| lastAnnouncedVideoId | varchar | null | Idempotency key — prevents double-announcing the same livestream |
| subscriptionExpiresAt | timestamptz | null | Estimated WebSub lease expiry (informational; re-subscription runs daily regardless) |
| isActive | boolean, default true | Toggled via PATCH /v1/youtube-integration — subscribes/unsubscribes the WebSub lease accordingly |
Lives in public, not the tenant schema, on purpose: a WebSub notification arrives from Google’s hub with no
Host header or any other tenant-identifying context — only a channel id inside the Atom XML payload. “Which tenant
owns this channel” has to be answerable before the tenant is known, which per-tenant-schema data structurally can’t
support (same reasoning as TenantCommunicationProviderConfig, docs/MULTI_TENANT_MIGRATION.md §4.12).
channel_id is UNIQUE across the whole table, which is exactly what makes the webhook’s tenant lookup a single
indexed query. Consequently v1/integrations/youtube/callback is excluded from TenantMiddleware (§4.3) — it’s
never resolved against a Host header, and tenant context for it is entered manually (below).
Platform-wide pieces (env vars): YOUTUBE_WEBSUB_CALLBACK_URL and YOUTUBE_WEBSUB_SECRET — the callback URL is
one physical endpoint regardless of how many tenants use it, and the HMAC secret authenticates the hub itself, not
any particular tenant. There is deliberately no platform-wide Data API key: a tenant who hasn’t set their own gets
no live-detection, silently, rather than quietly borrowing shared platform quota (YOUTUBE_API_KEY was removed —
see “No platform-wide API key fallback” below). With YOUTUBE_WEBSUB_CALLBACK_URL/YOUTUBE_WEBSUB_SECRET unset,
YoutubeSubscriptionService.isWebSubConfigured() is false and subscribe()/unsubscribe() are no-ops (logged at
debug level) — nothing breaks for a deployment that hasn’t set these, tenants just fall back to the Sermon Module’s
manual “Announce Live” trigger.
No platform-wide API key fallback: YoutubeLiveDetectionService.handleNotification returns immediately if
apiKeyEncrypted is unset — it never falls back to a shared key, even though any valid Data API key can technically
look up any public channel’s snippet. Each tenant’s own Google API quota is consumed by their own traffic only.
Pro-only, module + plan gated (@RequiresModule('youtube_integration') + @RequiresPlan — same treatment as
Tithe/Giving and Social Media: a BYOK integration gated on business-value grounds, since it costs Discuva nothing
regardless of a tenant’s usage). Gating stops at the config controller — an already-configured integration from
before this module existed keeps running in the background (webhook processing doesn’t re-check plan/module state),
same limitation as every other feature’s pre-existing data surviving a later gate.
Tenant self-service routes (AdminGuard + ModuleEnabledGuard + PlanGuard, tenant-scoped — single resource
per tenant, no :id/:channel param):
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /youtube-integration |
YOUTUBE_INTEGRATION_READ | Returns { channelId, hasOwnApiKey, isActive, subscriptionExpiresAt } | null — never the key itself |
| PUT | /youtube-integration |
YOUTUBE_INTEGRATION_WRITE | Body { channelId, apiKey? } — upserts this tenant’s config, encrypting apiKey if given. Rejects (409) a channelId already owned by a different tenant. Switching channels unsubscribes the old one before subscribing the new one |
| PATCH | /youtube-integration |
YOUTUBE_INTEGRATION_WRITE | Body { isActive } — enable/disable without touching the stored channel/key; subscribes on enable, unsubscribes on disable |
WebSub (PubSubHubbub) flow:
- Whenever a tenant’s integration is created/enabled (
TenantYoutubeIntegrationService.upsert()/setActive(true)),YoutubeSubscriptionService.subscribe(channelId)POSTs a subscribe request to Google’s public hub (https://pubsubhubbub.appspot.com/subscribe) for that channel’s video-feed topic, includinghub.secret(YOUTUBE_WEBSUB_SECRET) — the hub then HMAC-SHA1-signs every notification it sends with that secret. Daily at 2am church time,YoutubeSubscriptionScheduler(distributed-lock guarded the same wayFollowUpScheduleris) callsrenewAllActive(), which re-subscribes everyisActivetenant integration — WebSub leases expire (~5-10 days), so daily renewal keeps every tenant comfortably ahead of expiry regardless of what the hub grants. GET integrations/youtube/callbackhandles the hub’s verification handshake — echoes backhub.challengeverbatim (required by the WebSub spec) forsubscribe/unsubscribemodes,404otherwise.POST integrations/youtube/callbackreceives the actual “video published” notification — an Atom XML body containing both a<yt:videoId>and a<yt:channelId>. Before doing anything else,YoutubeWebhookControllerverifies theX-Hub-Signatureheader (sha1=<hex>) against an HMAC-SHA1 of the raw body computed withYOUTUBE_WEBSUB_SECRET, usingtimingSafeEqual— without this, the public callback URL would accept a forged POST with an arbitrary video/channel id from anyone who discovers it, triggering a fake “we’re live” push to a tenant’s members. A missing/mismatched signature, or no secret configured at all, is dropped silently. Both ids are extracted via small regexes (a full XML parser dependency wasn’t worth adding for two fixed fields). Always acks fast (204, no body) regardless of what happens next —YoutubeLiveDetectionService.handleNotification(videoId, channelId)runs without being awaited by the response.YoutubeLiveDetectionServicetakes a short Redis lock (lock:youtube-notification:{videoId}, 60s TTL) before doing anything else — WebSub hubs routinely redeliver the same notification, and without this, two concurrent deliveries could both pass the idempotency check below before either write lands, double-announcing the same stream. It looks up theTenantYoutubeIntegrationowning the notifiedchannelId(isActive: true) — an unrecognized or inactive channel is dropped silently, no error. It then checks that integration’slastAnnouncedVideoId(the actual idempotency check — the same video can generate multiple WebSub pings across retries/redeliveries). If new, requires the tenant’s own decrypted API key — returns silently if none is configured, no fallback — and calls the YouTube Data API (videos.list?part=snippet) to confirmsnippet.liveBroadcastContent === 'live'— the WebSub ping alone fires for regular uploads too, not just livestreams — and thatsnippet.channelIdmatches the notified channel id, since a forged/mismatched payload could otherwise attribute someone else’s video to this tenant’s announcement. Only then does it look up the owningTenant, manually enter that tenant’s CLS/transaction context (cls.runWith({tenantId, schemaName}, () => txHost.withTransaction(async () => { SET LOCAL search_path; ... }))— the same patternPlatformTenantService.impersonateTenantuses; a webhook has no request-scopedTenantMiddlewarerun to inherit tenant context from, so it has to open one itself), callcreateSystemAnnouncement()inside it, and persist the video id as the newlastAnnouncedVideoIdon the (public-schema) integration row afterward.- All external-call failures (hub POST, Data API call) are caught and logged as warnings, never thrown — a webhook handler that 500s risks the hub retrying or giving up on the subscription entirely.
Mixlr is not automated — it was never tightly coupled to begin with (a per-sermon manual URL field, already tenant-scoped) and no confirmed public webhook/API was found; the manual “Announce Live” trigger remains the only path for Mixlr-only streams. Facebook Live is not a built feature at all.
Routes: GET integrations/youtube/callback — no guard, verified implicitly by the WebSub handshake itself
(same trust model as POST webhooks/billing’s own signature verification). POST integrations/youtube/callback — no
NestJS guard either, but is signature-verified in the controller itself as described above (a Public()-style route
whose actual authentication is the HMAC check, not a bearer token). Both are excluded from TenantMiddleware (§4.3)
since the hub never sends a Host header identifying a tenant.
Env vars: YOUTUBE_WEBSUB_CALLBACK_URL, YOUTUBE_WEBSUB_SECRET — see Environment Variables.
ServiceHeadcount Module
Records and retrieves physical attendance counts for services, broken down by demographic group. All routes are admin-portal only (AdminGuard). Headcount data can be filtered by service slot, date range, or slot name; trends are bucketed by week, month, or quarter.
Entity: ServiceHeadcount — one record per service slot (OneToOne, enforced by a unique constraint on service_slot_id). POST /service-headcount is an upsert: recording again for a slot that already has a headcount edits that row in place instead of creating a sibling, so summing across a service’s sub-services never double-counts.
Computed total: Every response includes a total field (sum of fixed groups + all customGroups values). Not stored in DB.
Event-level summary (GET /service-headcount/event/:eventId/summary): The service-level view for a multi-service Sunday — returns every sub-service (ServiceSlot) under the event ordered by startTime, each with its headcount if recorded (null otherwise), plus an aggregate total summed across whichever sub-services have been recorded so far (recordedCount/slotCount show how many are still outstanding). This is the primary admin-facing view (app/service-headcount’s “By Event” tab) — an admin picks the Event once and records each sub-service’s count inline without leaving the page, and sees the full-service total without adding sub-services up by hand. Reuses the same 5-field-plus-custom-groups form as the flat POST route; no new DTO.
No separate correction endpoint (by design): PATCH /service-headcount/:id existed early on for correcting a record, consumed only by the Records tab’s now-removed “Edit” button (a flat historical list, separate from the “By Event” tab). Once headcount became upsert-on-POST, that PATCH route had no remaining frontend caller — removed entirely (controller route, service method, UpdateServiceHeadcountDto) rather than left as dead, unconsumed admin API surface. Corrections now happen exactly one way: re-recording the same sub-service through the “By Event” tab, which pre-fills the existing values and edits in place.
Trends: GET /service-headcount/trends returns bucketed data. Each bucket is keyed by periodLabel + serviceSlotName so multiple slots on the same Sunday appear as separate series. customGroups’ dynamic per-church keys mean the per-bucket aggregation stays in-memory rather than SQL GROUP BY, but omitting from now defaults to a bounded ~365-day lookback (defaultTrendsFrom()) instead of scanning every headcount record ever logged — an explicit from is always honored as-is.
Email export (POST /service-headcount/export-email): Reuses the same filtered query as the flat GET /service-headcount list (no pagination), builds an .xlsx via the shared ExcelService.buildWorkbook, and queues it as an email attachment via EmailQueueService.queueEmailWithTemplateAndAttachments using the shared report-export template (src/utility/templates/report-export.html, reused by every report’s export endpoint — not headcount-specific). Deliberately one-off: no recurring/scheduled export exists or is planned as part of this feature.
Trends charts (discuva-admin, app/service-headcount/page.tsx): the Trends tab has a Chart/Table toggle (defaults to Chart) consuming the same GET /service-headcount/trends response the table already used — no new backend endpoint, since HeadcountTrendPoint already carries every field the reference dashboard needed (maleAdults/femaleAdults/teenagers/children/serviceSlotName/periodLabel/total). Renders via three new reusable wrapper components (components/charts/bar-chart.tsx, pie-chart.tsx, trend-line-chart.tsx, thin wrappers over the new recharts dependency): a total-attendance trend line across period buckets, a per-service total bar chart, a gender-split pie chart, and a teens-vs-children bar chart — all aggregated client-side from the same trends payload. Fixed a pre-existing bug while wiring this up: the frontend’s Period type allowed "yearly", which isn’t a value the backend’s HeadcountPeriod recognizes — since the controller doesn’t validate/whitelist the query param, selecting “Yearly” silently fell through to quarterly bucketing server-side. Now "weekly" | "monthly" | "quarterly" on both sides.
Routes prefix: /service-headcount
Prayer Roster Module
Manages monthly prayer meeting rosters across one or more named programs. Each program has its own audience type (WORKERS, MEMBERS, or ALL), day configs, schedule rules, and roster entries. Multiple programs can run concurrently (e.g. a worker-only intercessory program alongside an open member prayer program).
Key flows:
- Admin creates programs via
POST /prayer/admin/programs. All subsequent operations pass?programId=to scope to one program. - Admin configures prayer days (
POST /prayer/admin/day-configs?programId=) and frequency rules (POST /prayer/admin/rules?programId=). - Admin generates meetings for a month (
POST /prayer/admin/meetings/generate?programId=). Fixed assignments are auto-applied at generation time. - Admin opens the self-selection window (
POST /prayer/admin/meetings/open-selection?programId=); workers and/or members browse open slots and submit their preference (POST /prayer/select?programId=). Members can only self-select on programs withaudience = MEMBERSorALL. - Admin runs auto-assign (
POST /prayer/admin/roster/auto-assign?programId=&month=&year=) to fill remaining gaps. Auto-assign is available forWORKERSandALLprograms; it clears allAUTO_ASSIGNEDentries first (idempotent), then re-runs the algorithm on clean state. Returns{ assigned, unassignable }. - Admin may manually assign any worker or member via
POST /prayer/admin/roster/manual-assign?programId=with{ meetingId, workerProfileId? | memberId? }. - Admin may remove any non-FIXED
SCHEDULEDentry viaDELETE /prayer/admin/roster/entries/:id. - Exact frequency enforcement (WORKERS/ALL programs): Every worker must be assigned to exactly their required number of slots.
GET /prayer/my-status?programId=returns{ required, selected, canSubmit }. - Concurrent self-selection: The
selfSelectflow runs inside aDataSource.transaction()with apessimistic_writelock on the meeting row to prevent capacity over-booking under concurrent requests. - Reschedule (soft-delete):
PATCH /prayer/admin/roster/entries/:id/reschedulemarks the old entryRESCHEDULED, creates a new entry withrescheduledFromFK, and adjustscurrentCapacityon both meetings. - Admin validates the completed roster (
GET /prayer/admin/roster/validate?programId=&month=&year=). Returns{ valid, issues[] }with per-worker frequency and per-meeting leader checks.
Reminder scheduler (daily at 08:00):
Queries prayer_roster_entries where the meeting date is 2 days or 1 day away and the corresponding flag (reminderTwoDaySent / reminderDaySent) is false. Queues email via UtilityService.sendEmailWithTemplate (fire-and-forget). All flag updates are batched into a single save() call after the loop. Template: prayer-reminder.html.
Routes prefix (admin): /prayer/admin
Routes prefix (worker): /prayer
Entities: prayer_programs, prayer_schedule_configs, prayer_day_configs, prayer_schedule_rules, prayer_fixed_assignments, prayer_meetings, prayer_roster_entries.
Migrations:
1785369600000-CreatePrayerScheduleConfig1785456000000-CreatePrayerDayConfigs1785542400000-CreatePrayerScheduleRules(also seeds 5 default rules)1785628800000-CreatePrayerFixedAssignments1785715200000-CreatePrayerMeetings1785801600000-CreatePrayerRosterEntries1785888000000-AddPrayerIndexes(indexes onreminder_two_day_sent,reminder_day_sent,statuson roster entries;status,selection_statuson meetings)1785974400000-PrayerColumnsToSnakeCase(renames all prayer table columns from camelCase SQL names to snake_case for TypeORM SnakeNamingStrategy compatibility)1786233600000-AddPrayerPrograms(createsprayer_programstable; addsprogram_idFK to day configs, rules, meetings; addsmember_idto roster entries; makesworker_profile_idnullable; backfills with a default “Prayer Program” row)1786320000000-AddFirstTimerConversionFields1786406400000-AddEventThankYouSentAt1786492800000-AddFirstTimerVisits1786579200000-AddFollowUpEnhancements1786665600000-AddAuditLogTargetName1786752000000-AddFollowUpTaskIndexes(indexes onassigned_to_id,status,typeonfollow_up_tasks)1786838400000-AddEmailLogProvider1786924800000-AddPushSubscriptions1787011200000-AddFinanceAccountCode1787097600000-AddPledgeGuestName1787184000000-AddMissingFkIndexes(13 FK indexes across high-traffic tables:attendances.service_slot_id,follow_up_tasks.(member_id, event_id),first_timer_visits.(first_timer_id, event_id),follow_up_notes.task_id,finance_journal_entry_lines.(journal_entry_id, account_id),finance_offerings.fund_id,finance_reconciliation_rows.job_id,tithe_records.(batch_id), compositetithe_records(member_id, payment_date),asset_checkouts.asset_id)1787875200000-CreatePledgeContributions(createsfinance_pledge_contributions:pledge_idFKCASCADE,submitted_by_idFK tomembersRESTRICT,reviewed_byFK toadminsSET NULL,amount,payment_date,reference,statusdefaultPENDING,reviewed_at,finance_note)1788393600000-AddPerformanceIndexes(compositemembers(birth_month, birth_day)for upcoming-birthday lookups;first_timers.created_atfor date-range queries; compositefollow_up_tasks(status, due_date); single-columnstatusindexes ontithe_upload_batches,tithe_unmatched_records,tithe_dispute_records,tithe_payment_proofs;finance_requests.category_id)1792652400000-AddEmailLogSource(adds nullableemail_logs.source—tenantvsplatform_default; existing rows areNULL)1792738800000-AddSocialAccountOAuthTokens(tenant — addssocial_accounts.access_token_encrypted/refresh_token_encrypted/token_expires_at/scope)1792825200000-AddSocialPostMediaPlacementScheduling(tenant — addssocial_post_targets.placement,social_posts.scheduled_for, dropssocial_posts.image_url, createssocial_post_media)1792911600000-AddMemberDirectoryProfiles(tenant — createsmember_directory_profiles, indexed onis_visible)1793217600000-AddSocialPlatformApps(public/control-plane — createssocial_platform_apps, the platform-admin-owned OAuth app catalog)1793304000000-AddMemberDirectoryToProPlan(public/control-plane — appends'member_directory'to theproplan’sfeaturesarray)
Push Notification Module
Delivers Web Push notifications to members and workers via the standard Web Push protocol (VAPID). Backed by the web-push npm package and a dedicated Bull queue (push-notifications).
Key flows:
- Subscribe (once, on first device registration): After
POST /auth/loginregisters the device for the first time (deviceIdtransitions fromnull), the PWA service worker callspushManager.subscribe()and POSTs the result toPOST /v1/notifications/subscribe. This is a one-time setup per device — not called on every login. Calling subscribe again replaces the existing row. - Subscription lifecycle: The subscription persists through logouts. Push notifications are delivered via the browser service worker and fire even when the member is not logged in. The subscription is removed only in these cases:
- Admin device purge (
DELETE /admin/members/:id/device): backend deletes the subscription automatically. The frontend must re-subscribe after the member’s next login on the new device. - OTP device reset (
POST /auth/device-reset/verify): backend deletes the subscription automatically. The frontend must re-subscribe after the member’s next login on the new device. - Explicit opt-out: member calls
DELETE /v1/notifications/subscribe. - Stale subscription: push service returns
410 Goneor404— processor deletes it automatically, no retry. - Key mismatch: push service returns
403saying the subscription was made with a different VAPID key (AppleVapidPkHashMismatch, FCM “do not correspond”) — deleted the same way, since it can never be delivered.
- Admin device purge (
- VAPID key source: clients must subscribe with
GET /notifications/vapid-public-key(the API’s ownVAPID_PUBLIC_KEY), not a separately configured copy. Incident (fixed 2026-09-29): the member app was built with a differentNEXT_PUBLIC_VAPID_PUBLIC_KEYthan the API’s key pair, so every push since launch was rejected by Apple/FCM. The member app now fetches this key and, once per session for opted-in members, replaces any subscription made with a different key and re-registers it. PushNotificationService.dispatchToMemberIds(memberIds, payload)finds subscriptions and enqueues all subscribers’ jobs in a singlequeue.addBulk()call (each still keeps its own stablejobId,push:{memberId}:{idempotencyKey}, for deduplication) rather than onequeue.add()round trip per subscriber — matters most for large-fanout sends (e.g. anALLaudience announcement to thousands of members).PushNotificationService.dispatchToWorkerProfileIds(workerProfileIds, payload)resolves worker profile IDs to member IDs via a single SQL query, then delegates todispatchToMemberIds.PushNotificationProcessorprocesses each job: checks a Redis idempotency key (notif:sent:{memberId}:{idempotencyKey}, 24 h TTL) before sending. On410 Gone,404or a403key mismatch from the push service, the subscription is deleted — no retry. Any other error is re-thrown asPush service responded <status>: <body>for Bull to retry (3 attempts, exponential backoff), so the failed job records the push service’s actual reason.NotificationDispatchService(src/utility/service/notification-dispatch.service.ts, exported from the@Global()UtilityModule) —notifyMember({ category, email?, push? })fires the email and push legs of one notification together. Since the Email/Push switch split, each leg has its own per-church switch: email checksEmailCategorySettingsService.isEnabled(category)here, and the push leg (a catalogue key + vars) is gated byisPushEnabledinsidePushNotificationService. Originally both legs shared one check. Introduced because several call sites (ServiceProgrammeService.notifySlotAssignment,EventReminderService.fireReminder) queued email through that gate but dispatchedPushNotificationService.dispatchToMemberIds()completely unconditionally — an admin disabling a category’s emails silently left push still firing for the same event. Eitheremail/pushoption is independently optional (send email-only, push-only, or both — e.g.fireReminderonly setspushwhenrecipientIdsis non-empty), but the category gate always applies to both uniformly; there’s no per-channel opt-out below the category level. New notification-worthy events should be wired through this rather than callingEmailQueueService/PushNotificationServicedirectly, to get the same-gate guarantee for free.
Trigger points:
| Event | Who is notified |
|---|---|
Selection window opened (openSelectionWindow) |
All active workers |
Auto-assign completes (autoAssign) |
Each newly assigned worker (via notifySlotAssignment, alongside email — see note below) |
Manual assignment (manualAssign) |
The assigned worker or member (via notifySlotAssignment, alongside email — see note below) |
Entry removed (removeEntry) |
The affected worker or member |
Entry rescheduled (reschedule) |
The affected worker or member (via notifySlotAssignment, alongside email — see note below) |
Prayer reminder — 2 days before (PrayerReminderScheduler) |
The assigned worker (alongside email) |
Prayer reminder — day of (PrayerReminderScheduler) |
The assigned worker (alongside email) |
Service/event reminder (EventReminderService) |
All eligible members per audience scope (alongside email — see note below) |
ServiceProgrammeService.notifySlotAssignment (create/auto-assign/manual-assign/reschedule paths) and EventReminderService.fireReminder both route through NotificationDispatchService.notifyMember() (see “Notification Dispatch Service” above) — their push leg is gated by EmailCategorySettingsService.isEnabled(...) the same way their email leg always was, instead of firing unconditionally. The other rows above (selection window, entry removed, prayer reminders) call PushNotificationService directly and remain ungated by category preference — candidates for the same migration in a future pass, per the progressive rollout this was scoped to.
Entity: push_subscriptions — id, member_id (unique FK → members), endpoint, p256dh, auth, created_at, updated_at.
Environment variables required: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT (see §10).
Facility Rental Module
Manages bookable facility slots (halls, rooms, etc.) for members and workers, with tier-based discounts, add-ons, and payment tracking.
Key flows:
- Admin configures facilities (
POST /facility-rental/admin/facilities), pricing tiers (POST /facility-rental/admin/pricing-tiers), add-ons (POST /facility-rental/admin/addons), and calendar blackout blocks (POST /facility-rental/admin/calendar-blocks). - Members/workers browse active facilities and available add-ons, check availability via
GET /facility-rental/facilities/:id/availability?from=&to=, and submit a booking (POST /facility-rental/bookings). - On booking creation, the service resolves the member’s category (LEADER if a
DepartmentLeadrecord exists, WORKER ifrole = WORKER, otherwise MEMBER), looks up the matching pricing tier, and computes a price snapshot. Caution amounts are never discounted. - Pricing formula:
serviceFee = (basePrice + sum(addon prices)) × (1 - discount),grandTotal = serviceFee + sum(addon caution amounts). - On creation, two
RentalPaymentrows are generated — oneSERVICE_FEEand oneCAUTION(skipped if caution total is zero). Both startPENDING. - Overlap check:
createBookingand calendar blocks both use a time-range overlap query (start < newEnd AND end > newStart) against all non-cancelled/rejected bookings. A calendar block also blocks the slot. - Admin confirms (
PATCH .../confirm), rejects (PATCH .../reject), or applies a one-off discount override (PATCH .../discount). Overrides recompute and update theSERVICE_FEEpayment record in place. - Admin marks payments as paid (
PATCH /facility-rental/admin/payments/:id/paid) and marks caution refunded (PATCH /facility-rental/admin/payments/:id/refund) after the booking completes.
Status scheduler (every 10 minutes): RentalStatusScheduler auto-transitions CONFIRMED → IN_PROGRESS when startDateTime ≤ now < endDateTime, and IN_PROGRESS → COMPLETED when endDateTime ≤ now.
Routes prefix (admin): /facility-rental/admin
Routes prefix (member/worker): /facility-rental
Entities: rental_facilities, rental_pricing_tiers, rental_addons, rental_bookings, rental_booking_addons, rental_payments, rental_calendar_blocks.
Migration: 1782303229675-CreateFacilityRental
Permissions: FACILITY_RENTAL_READ, FACILITY_RENTAL_WRITE
Children Church Module
Provides a security-grade check-in/check-out system for children. Key features:
- Children are automatically assigned to an age group and class group based on date of birth. Running
POST /children-church/age-groups/recomputere-evaluates all children against current age-group rules. - Each check-in generates a unique 6-character pickup code. The code is emailed to all registered guardians at check-in time.
- Pickup is verified by code via
GET /children-church/checkin/verify/:codebefore the checkout is submitted. - Any check-in can be flagged with
PATCH /children-church/checkin/:id/flag(e.g. unknown pickup attempt). - Multiple guardians can be registered per child;
isAuthorizedPickupcontrols who may collect. - Admins (not workers) can view live active check-ins across all classes via
GET /children-church/admin/checkin/activeand paginated history viaGET /children-church/admin/checkin/history. GET /children-church/children/:idcarries a transientvisitCount(totalChildCheckInrows for that child, not persisted — same pattern asFirstTimer.visitCount) so a child’s own attendance history reads as a single number, not just a paginatedcheckin-historylist.- A
ChildProfileis not aMemberorFirstTimer— a child has no digital-footprint timeline of their own. Instead,MemberTimelineService.getTimeline()carries achildrenChurchDropOffscount: everyChildCheckInwhere the member is thedroppedOffBy/pickedUpByChildGuardian(found viaChildGuardian.member), across every child they guard. This tracks the guardian’s engagement, not the child’s, and is deliberately kept separate fromvisitCount(which is the member’s own visits) rather than folded into it — conflating “I visited” with “my kid was dropped off” would be misleading.ChildGuardian/ChildCheckInare registered read-only inMemberModule(not by importingChildrenChurchModule, which already importsMemberModule— that would be circular), same pattern asSundaySchoolAttendance/Attendance.
Routes prefix: /children-church
ServiceProgramme Module
Backend replacement for the Firebase-based Service Timer POC. Manages service programme creation, live session control, real-time state broadcast, and post-session analytics.
Architecture:
-
ServiceProgrammeand its slots are authored during the week (DRAFT status). -
When a session starts, the programme transitions to LIVE and a
ServiceSessionis created along withServiceSessionSlotsnapshot rows (one per programme slot). -
Live state (current slot, timer anchor, pause state) is held in Redis. Clients compute the display timer locally using:
elapsed = slotBaseSeconds + (Date.now() - slotStartedAt) / 1000. -
Every state change (advance, rewind, pause, resume, adjust-time, reorder, override) updates Redis and writes durable records to the DB.
-
Live sessions support ad-hoc time adjustment (
adjustTime, ± seconds applied to the running slot’s elapsed time — reuses the same recompute pattern asresume()) and reordering of the not-yet-started (PENDING) tail ofServiceSessionSlotrows via drag-and-drop in the admin UI (reorderLiveSlots— distinct fromServiceProgrammeService.reorderSlots, which only reorders DRAFT-statusServiceProgrammeSlotrows before a session starts). -
effectiveSlotsonGET /service-session/:code/state— a flattened, per-session array ({ id, position, status, type, topic, allocatedMinutes, memberName, guestName, backupMemberId, backupMemberName, backupGuestName, actualSeconds, startedAt, completedAt }, built bywithEffectiveSessionSlotsinutil/slot-display.ts) keyed byServiceSessionSlot.id, with each slot’s DRAFT-template fields (programmeSlot.topic/allocatedMinutes/member/guestName) merged with any live overrides (overriddenTopic,overriddenSpeakerName,overriddenMember,adjustedAllocatedMinutes) already resolved. This is the array every live frontend view (/service-programme/live/:sessionCode,/live/:code/manage,/live/:code/presentation,/live/:code/audience,SessionRunner) reads for its slot list — fixed a bug where those views previously readsession.programme.slots(ServiceProgrammeSlot[], the DRAFT template’s own IDs) and passed those ids straight intoreorderLiveSlots, which validates againstServiceSessionSlotids and therefore always threwBadRequestException('Slot list must contain exactly the upcoming (not-yet-started) slot IDs')— reordering during a live session could never actually succeed. Thebackup*fields always reflect the DRAFT slot’sbackupMember/backupGuestNameas-is — there is no “override the backup” concept, so they stay constant even after the primary speaker has been overridden. -
overrideSlot(POST /service-session/:code/slots/:position/override,RolesGuard+WORKER, andPOST /service-session/:code/pm/slots/:position/override,Public+ShareTokenGuard) lets an operator rename a slot’s topic and/or swap its minister/speaker (by linking aMemberviaoverriddenMemberId, or a free-text guest name viaoverriddenSpeakerName) while the session is live, from either the authenticated Live Session Dashboard or the public Programme Manager link — both call the same service method, withmemberId: nullon the PM path (same pattern asadvance/rewind/etc.). The override is stored on theServiceSessionSlotrow, never mutates the underlying DRAFTServiceProgrammeSlot, and is immediately reflected ineffectiveSlots(and therefore every view listed above) on the next poll. -
Swap to backup — both the Live Session Dashboard and the Programme Manager view render a “Backup: {name} — tap to swap” affordance on any slot that has one (current slot and each upcoming slot in the queue), computed client-side by
backupLabel/backupOverridePayloadinuse-service-session.ts. Clicking it calls the sameoverrideSlotendpoint withoverriddenMemberId(if the backup is a linkedMember) oroverriddenSpeakerName(if it’s a guest) — a one-click fallback for when the primary speaker doesn’t show up, no re-search required. The Presentation and Audience views deliberately do not show backup info — it’s operational/control-surface data, not something the congregation needs to see. -
Adding or editing a slot on an already-created DRAFT programme (
ProgrammeDetailPanel) exposes the same “Add backup speaker” toggle as the creation dashboard’sItemEditorRow(see below) — both flows share the one component, so backup assignment works identically whether you’re building a programme for the first time or coming back to edit it later. -
Each session also gets a
shareToken(random UUID, Redis-only, same TTL/lifecycle as the anchor) generated instart(), powering three public, unauthenticated frontend routes, all readingGET /service-session/:code/state— no new backend endpoints were needed for any of them:/live/:code/presentation(read-only, big-screen display, dark theme, meant to be opened in its own browser tab/window via the “Open Presentation Window” button so it can be dragged to a second monitor/projector and fullscreened with theFshortcut),/live/:code/manage?token=...(full remote control — advance/rewind/pause/resume/adjust-time/reorder/end, share-token gated), and/live/:code/audience(read-only, mobile-first, light theme — current slot + countdown + progress bar, “Up Next”, and the full running order with done/current/upcoming state; intended for members/workers to follow along on their own phone during the service, no share token required since it’s read-only like the presentation view). Admins copy/open these links from the “Presentation Link” / “Presentation Window” / “Programme Manager Link” / “Audience Link” buttons on the service-programme, service-session, and live-session dashboards (GET /service-session/:code/share-links). The link can be invalidated without ending the session viaPOST /service-session/:code/rotate-share-token, which overwrites the Redis key with a freshly generated token — any copy of the old link stops working immediately (this only affects the Programme Manager link; the Presentation and Audience links have no token to rotate). -
Ending a live session always requires an explicit two-step confirmation (“End” → “End?” Yes/No) in every surface that can end one — the authenticated Live Session Dashboard, the
SessionRunnercard, and the public Programme Manager view — so a single stray click can never terminate a session. -
rewindis destructive — it resets the current slot back to PENDING and reopens the previous slot as IN_PROGRESS, unconditionally nulling that previous slot’scompletedAt/actualSeconds(there is no shadow/history column, so a mistaken rewind previously destroyed the recorded actual duration of a finished slot with only an audit-log breadcrumb — no way to recover the numbers). Two mitigations: (1)rewind()now reads both affectedServiceSessionSlotrows before overwriting them and stringifies their priorstatus/startedAt/completedAt/actualSecondsinto theREWIND_SLOTaction-logdetailfield, so the destroyed values are recoverable from the audit log/CSV even though the DB row itself is overwritten; (2) every UI surface that can trigger rewind (Live Session Dashboard,SessionRunner, public Programme Manager view) now requires an explicit Yes/No confirmation before calling it, mirroring the “End Session” pattern. Adjusting the timer (adjustTime, ± seconds) is non-destructive to slot records but still requires the same Yes/No confirmation in the Dashboard and Programme Manager views, since a mis-tap changes the running countdown an operator and congregation are actively watching. -
All four live-session views only overwrite their local
payloadstate when a fetch actually succeeds — a failed fetch (a transient error, a network blip, a brief server restart) leaves the last known-good payload in place rather than nulling it out, so a momentary hiccup is never mistaken for “the session has ended.” Only a fetch that succeeds and returnsanchor.status === 'COMPLETED'(or an initial load that never succeeds at all) shows the ended/not-found state. -
Live updates moved from per-client polling to Socket.IO push (
ServiceSessionGateway, namespace/service-session) — the original design had all four views (Dashboard, Programme Manager, Presentation, Audience) independently pollingGET /service-session/:code/stateevery 1.5–3s. That cost scales withsessions × viewers-per-session, notsessions— and the Audience view has no cap on viewers at all (any number of congregants can open it on their own phone), so a single popular session’s Audience traffic alone could dwarf everything else on a busy Sunday. The gateway and its Redis-backed adapter (RedisIoAdapter,@socket.io/redis-adapter, wired at bootstrap inmain.tsfor horizontal fan-out across multiple app instances) already existed from an earlier phase but had zero consumers and an incomplete broadcast payload; this phase completed the wiring:broadcastState(sessionCode, state: SessionStatePayload)now emits the exact payloadGET /statereturns (anchor,session,effectiveSlots,cautionThresholdRatio— previously onlyanchor/sessionwere sent, missing the slot data every view actually renders from). Every mutating controller method (advance,rewind,pause,resume,adjustTime,reorderLiveSlots,overrideSlot,end, and allpm/*equivalents) re-fetches full state viagetState()and broadcasts it to roomsession:${sessionCode}after the mutation commits.joinSession/leaveSessiongate room membership by validating the session code exists (getState()succeeds) before joining — previously any client could join any room, including nonexistent ones, with zero validation. This is a read-only channel with the same trust model as the public REST routes it replaces (session code = read credential); no write actions happen over the socket.- CORS on the gateway is validated dynamically via
createCorsOriginValidator()(same shared validator as the HTTP API, see §Multi-Tenant Request Scoping’s “CORS origin validation” note) instead of the previous wide-openorigin: '*'. - Frontend:
hooks/use-live-session-socket.tsconnects to the namespace, joins the session’s room, and calls back into each view’ssetPayloadon everysession:stateevent. Each of the four views kept only a much slower (30s) safety-netsetIntervalpoll as a fallback for the rare case a broadcast is missed during a disconnect/reconnect or a backend restart — this is a defense-in-depth measure, not the primary update path anymore. The socket origin is derived fromNEXT_PUBLIC_API_URL’s origin (stripping the versioned/v1path — Socket.IO attaches to the raw HTTP server, not the REST prefix). - The per-IP
@Throttle({ limit: 300, ttl: 60_000 })override onGET :code/state/GET :code/slots/:positionis left in place for the initial load and safety-net poll, but is no longer the thing standing between this module and a real capacity problem — it never bounded aggregate load across many distinct viewer IPs in the first place. handleConnectionadds a separate, additive, authenticated tenant-room join — used only for a globalactiveSessions:changedbroadcast (which sessions are currently LIVE for the tenant), distinct from the anonymous per-session-code rooms above. A client presenting a valid JWT athandshake.auth.token(verified the same wayTenantMiddlewareverifies a tenant claim — access secret first, then refresh secret) is joined totenant:${tenantId}; a missing or invalid token is a silent no-op, never a rejected connection, so the anonymous audience/presentation/manage flows are completely unaffected.ServiceSessionController.start/startEvent/end/pmEnd— the four actions that change whether any session is live — each callgateway.broadcastActiveSessionsChanged(tenantId, sessions)(tenant id read from the request’s CLS store, same asTenantMiddlewarewrites it) after their existingbroadcastStatecall. Frontend:hooks/use-active-sessions-socket.tsconnects with the current access token fromtokenStore;useActiveSessions()(mounted globally viaLiveSessionPillinShell, so it runs on almost every authenticated page) consumes it in place of its previous 20-60s unconditional poll, keeping only a 5-minute safety-net poll as a fallback.
-
The presentation view’s countdown has three visual states: normal (white) → caution (“Wrapping Up”, amber, pulsing) once remaining time drops to
SERVICE_SLOT_CAUTION_THRESHOLD_RATIO(env, default0.25, i.e. the last 25% of the slot’s allocated time) → overtime (“Time’s Up”, red, pulsing) once elapsed exceeds the allocation, after which the display counts up (+MM:SS). The ratio is resolved server-side and returned ascautionThresholdRatioonGET /service-session/:code/state, so the frontend has a single source of truth rather than duplicating the value in its own env config. SeeSlotTimerDisplay(frontend). The presentation page also supports a keyboard shortcut (F) to toggle browser fullscreen via the Fullscreen API. -
When a
ServiceProgrammeSlotis assigned a member (viaaddSlotorupdateSlot’smemberId),notifySlotAssignment()fires both channels viaNotificationDispatchService.notifyMember()(see “Notification Dispatch Service” below) — email requires the member to have an address on file, push doesn’t (a member may have one channel but not the other, and neither blocks the other), but both are gated together by the sameEmailCategorySettingsService.isEnabled(EmailCategory.SERVICE_PROGRAMME_ASSIGNMENT)check:- Email (template:
service-slot-assigned) if the member has an email on file, with a generated.icscalendar invite attached when the underlyingServiceSlothas both astartTimeandendTime. The template body includes the formatted service date ({{ serviceDate }}, e.g. “Sunday, 19 July 2026”) and time range ({{ serviceTime }}, e.g. “8:00 AM – 10:00 AM”) as plain text in the “Your slot” attributes table — not just carried in the.icsattachment, so the schedule is readable even without a calendar client. Both fields are computed once viafmtAssignmentDate/fmtAssignmentTimeand reused for the push body below. - Push notification to the assigned member.
idempotencyKey: service-slot-assigned:${slot.id}:${member.id}keys it per slot-and-person so a primary/backup reassignment on the same slot doesn’t dedupe against each other. Body includes the slot type, service name, and date/time when available (e.g. “Speaker — Sunday Service — First Service on Sunday, 19 July 2026 at 8:00 AM – 10:00 AM”); links to/eventsin the member app.
Before
NotificationDispatchServiceexisted, push was dispatched unconditionally — an admin disabling this category’s emails silently left push still firing for the same event. Fixed by routing both legs through the shared category gate instead of only checking it on the email leg.Guests (
guestName, no member record) never reach this method — nothing to email or push. Re-editing a slot without changing its assigned member does not re-send either notification. The response fromaddSlot/updateSlotmay include a non-blockingconflictWarningstring when the assigned member already has another slot (in a different programme) whose service time overlaps this one — surfaced in the admin UI but never prevents the save. - Email (template:
-
Assigning a backup member (
backupMemberId, via the same two endpoints) triggers the identical email + push pair for the backup, withisBackup: truein the template data — the template ({{#if isBackup}}) swaps the heading/body copy to make clear they’re the backup, not the primary, and the subject/push title reads “You’re the Backup for: …” instead of “You’ve Been Added to the Programme: …”. Same at-most-once-per-change rule as the primary: re-editing a slot without changing the backup does not re-send. -
POST /service-programme(create) is fully batched, not one round trip per programme/slot. Given N programmes (each with its own set of slots — e.g. creating First Service and Second Service’s whole order-of-service in one request), the previous implementation looped per programme (programmeRepo.saveonce each) and, within that, per slot (amemberRepo.findOnefor the assignee, another for the backup, thenslotRepo.save) — a 2-programme, 15-slot-each request was 60+ sequential DB round trips. It now: resolves every referencedmemberId/backupMemberIdacross every programme’s slots in onememberRepo.find({id: In(...)}), bulk-inserts all programmes in oneprogrammeRepo.save(array), bulk-inserts all slots in oneslotRepo.save(array), and reloads all created programmes for the response in oneprogrammeRepo.find({id: In(...)})instead of an N-timesfindOne. No change to the request/response shape. One intentional side-effect of the batching: the per-slotconflictWarningcomputation (findMemberConflictWarning) is no longer run duringcreate()— its result was already discarded here even before this change (onlyaddSlot/updateSlot’s single-slot paths surface it), so skipping the computation removes wasted queries without changing any observable behavior. -
create()sends one consolidated notification per member instead of one per slot. When the same person is assigned (as primary or backup) to multiple parts across the programmes in a singlecreate()call — e.g. a worship leader rostered for both First and Second Service in the same request — the old per-slotnotifySlotAssignment()loop sent one separate email and one separate push per assignment, so a member on 3 slots got 3 emails.create()now builds anassignmentItems[]list across all programmes/slots being created and hands it to a new privatenotifyBulkSlotAssignments(), which groups by member and, per member: still sends one push per assignment (unchanged — pushes are terse enough that batching them into one buys nothing and would lose the per-assignmentidempotencyKey: service-slot-assigned:${slot.id}:${member.id}deduplication), but sends one email (template:service-programme-assignments, new file) covering every part, with one.icsattachment per assignment. Each part in the email numbers itselfpartNumber(precomputed server-side asindex + 1when building the template data — Handlebars’ built-in@indexis 0-based and this codebase registers no custom helpers, so a 1-based@index1doesn’t exist and can’t be relied on in the template); a backup assignment renders as “Backup for: {slot}” instead of a numbered part. Subject is “You’ve Been Added to the Programme: {slot}” for a single assignment, or “You’ve Been Added to {N} Parts of the Programme” for multiple. Still gated byEmailCategorySettingsService.isEnabled(EmailCategory.SERVICE_PROGRAMME_ASSIGNMENT)and still skips the email leg for a member with no address on file, same as the single-assignment path. This only applies tocreate()—addSlot/updateSlot(adding or editing one slot on an already-created DRAFT programme) still call the originalnotifySlotAssignment()and send one email per call, since batching those would require deferring/debouncing a notification across separate, independent requests rather than grouping work already known to be one request — out of scope for this pass. -
ServiceProgrammeReminderSchedulerruns daily at 09:00 (@Cron('0 9 * * *'), guarded by a Redis lock so only one instance runs it) and emails a reminder (template:service-slot-reminder, same.icsattachment logic as the assignment email) to every assigned member whoseServiceProgrammeSlot.reminderSentAtis still null and whose programme is DRAFT with aServiceSlot.startTime24–48 hours away.reminderSentAtis stamped immediately after queuing to guarantee at-most-once delivery even if the cron overlaps a slow run. -
ProgrammeAutoStartScheduler(opt-in, off by default) starts a service’s session on its own, without a worker tapping “Start” — for churches that want the live session to begin the moment the scheduled time arrives rather than relying on someone remembering to start it. Gated per-EventConfigbyautoStartSession(boolean, defaultfalse; no per-slot override — a config-wide default was judged sufficient rather than adding a second admin UI surface preemptively). Runs every 5 minutes (@Cron('*/5 * * * *'), same Redis-lock +forEachActiveTenantshape as the reminder scheduler above); per tenant, queriesServiceProgrammes that areDRAFT, whoseserviceSlot.config.autoStartSessionis true, and whoseserviceSlot.startTimefalls between start-of-day (church-local, viaDateService.startOfDay()) andnow— that lower bound is deliberate, since an unbounded query would resurrect every ever-forgotten DRAFT programme, not just today’s. This was previously a tight 10-minute trailing window ([now - 10min, now]), which was itself a bug: a later slot in a multi-slot event (e.g. Second Service) only becomes startable once the prior slot’s session is manually ended, which a front-desk worker might do well after the slot’s own nominalstartTime— a 10-minute window meant that by the time the prior session was finally ended, the next slot’sstartTimehad already scrolled out of the window, permanently stranding it inDRAFTfor the rest of the day. Anchoring to start-of-day instead keeps every one of today’s due slots eligible all day, while still refusing to resurrect aDRAFTprogramme genuinely forgotten from a previous day. -
Each due event starts the specific due programme, not “whatever’s earliest for the event” — the batch is grouped by
serviceSlot.event.id, and for an event with more than one due slot in the same run, only the earliest-due one’sprogrammeIdis passed toServiceSessionService.startEvent(eventId, null, programmeId); a later due slot for the same event is picked up on the run after this one ends, same as the manual “Start” button’s own one-slot-at-a-time behavior.startEvent’s third, optionalprogrammeIdparameter is what makes this possible — when given, it’s started directly; when omitted (every other caller, including discuva-admin’s “Start” button), it falls back to the original “earliest startable DRAFT programme for this event” lookup, unchanged. This is a fix, not the original design: previously the scheduler passed onlyeventId, always falling into that “earliest DRAFT for the event” fallback — which doesn’t know or care whether that earliest programme was itself due or even auto-start-eligible at all. A church that configures a multi-service Sunday’s order of service for both services in advance, withautoStartSessionenabled on only the later one (the earlier one is always started manually, and may still legitimately be sittingDRAFTbecause nobody has gotten to it), would have the scheduler silently auto-start the earlier, not-due, not-auto-start-eligible programme instead of the one that was actually due — auto-start effectively never working for that config.startEvent’s existing guard against a second concurrently-LIVE session for the same event is unchanged either way (aConflictExceptionhere is an expected, silent skip — e.g. a second due slot in the same batch that the first call already handled — not logged as a failure). One event’s unexpected failure (e.g. a programme somehow ending up with zero slots) is logged and skipped without blocking the rest of the tenant’s batch. -
ServiceSessionService.start/.startEventacceptmemberId: string | null—nullmeans no human actor, used byProgrammeAutoStartSchedulerabove.assertCanControlSessionis skipped entirely whenmemberIdis null (if (memberId) await this.assertCanControlSession(memberId);, the same optional-actor pattern several sibling methods in this service already used for their own call sites), and the resultingSESSION_STARTEDServiceActionEntrygetsperformedByMember: nullwithactorLabel: 'Auto-started'instead of attributing it to a member — surfaced in the action log the same way a named Programme Manager grant’sactorLabelalready is. The two controller routes below are unaffected — they always pass the calling admin’s realreq.user.id. -
GET /service-session/:code/action-log/csvstreams the fullServiceActionEntryaudit trail for a session as a CSV download (Timestamp, Actor Role, Actor, Action, Detail) for admins who need an offline record beyond the in-app log;GET /service-session/:code/action-logreturns the 10 most recent entries as JSON for the dashboard’s in-app activity feed.GET /service-session/:code/report/pdf(session report PDF),GET /service-session/event/:eventId/report/pdf(full event report), andGET /service-session/event/:eventId/report/summary-pdf(event summary) all existed on the backend with no frontend consumer for a while — all three are now wired: the Live Session Dashboard’s “Share & Access” card has a “Session Report (PDF)” button (next to “Audit Log (CSV)”) for the first; the Programmes list’s per-event header has “Full Report”/“Session Report”/“Summary” download buttons for the other two (see Service Programme Module notes below for their distinct availability gating). -
Session report fixes —
buildSessionReport()'stotalPauseDurationSecondsonly ever summed pause entries that had aresumedAt(ServicePauseEntry.resumedAt: Date | null); a session ended while still paused left that final pause entry withresumedAt: nullforever, silently dropping its entire duration from the total (and showing “ongoing” in the pause log indefinitely).end()now closes any still-open pause entry (resumedAt IS NULL, same query used byadvance()/resume()) inside its existing transaction before finalizing the session, so the total and the pause log are always accurate once a session ends. Separately, the session-report PDF’s per-slot table (PdfService.drawSessionReport/drawFullEventReport) dropped its “Type” column and split the previous combined “Topic / Speaker” column (joined with·) into two distinct “Topic” and “Speaker” columns — removing the Type column on its own would have made any slot with no topic set indistinguishable from another (SessionSlotReport.topicis nullable), so the newPdfService.slotTopicLabel()falls back to a human-readable type label (ServiceSlotTypeLabels[type], e.g. “Praise & Worship”) whenever a slot has no topic, rather than a bare “—”;drawEventSummaryReport’s already-separate Topic/Speaker columns anddrawOrderOfServiceTable’s topic column were both updated to use the same shared helper for consistency. The single-session PDF also gained a small Analysis section — a handful of narrative, presentation-only insights derived from data already onSessionReport(on-time/over/under completion counts and combined variance, skipped-slot count, the single biggest overrun slot, and a pause summary with the most common reason) — rendered between the Pause Log and the closing time-summary band. These insights are computed inPdfServiceat render time and are deliberately not added to theSessionReportJSON contract or theGET /service-session/:code/reportresponse. The Pause Log table (in bothdrawSessionReportanddrawFullEventReport) also stopped rendering a bareSlot ${p.slotPosition + 1}—ServicePauseEntryonly ever stored the slot’s numeric position with no name, so the report showed unexplained entries like “Slot 3” with nothing tying it back to the actual slot.PdfService.pauseSlotLabel()now resolves that position back to the matchingSessionSlotReportand reusesslotTopicLabel(), so the Pause Log shows the same human-readable topic/type label as the Slots table above it. -
GET /service-session/:code/pm/report/pdf— the same session report PDF, now also reachable from the public Programme Manager link (ShareTokenGuard+NamedAccessGuard, controller delegates to a shared privatesendSessionReportPdfhelper alongside the admin route to avoid duplicating the response-header logic).getReportPdf/getFormattedReporthave no session-status precondition — this works whether the session is still LIVE or already COMPLETED — but the frontend surfaces it specifically on the manage page’s “Session Ended” screen, since that’s the point a PM user actually wants it. This required reordering/live/:code/manage’s early-return checks: the name+PIN sign-in gate now runs before the “Session Ended” check (previously the reverse — anyone with just the raw link could see the ended-session message with no PIN at all, and a report-download button placed there would have failed silently for anyone who hadn’t signed in). Now reaching the ended-session screen guarantees agrantTokenis already in hand. -
Admin frontend information architecture: the
GET /service-programmelist groups programmes under their parent event (using theevent/serviceSlotDetailfields above) instead of rendering every service slot as an unrelated row, so multi-slot events (e.g. First/Second Service on the same Sunday) visibly belong together. A persistent “Live” pill in the admin top bar (useActiveSessions, pollingGET /service-session/active) is reachable from any page and deep-links straight into a dedicated full-width Live Session Dashboard at/service-programme/live/:sessionCode— replacing the old cramped side-panel controls, which now show only a status summary with a link to the dashboard. The poll interval backs off adaptively: 20s while at least one session is LIVE, 60s while idle (the common case, since most of the time nothing is live) — this cut the steady-state request volume from this always-mounted, every-page component by 3x without slowing detection of a session actually starting/ending. -
All four live-session frontend surfaces (Live Session Dashboard, Programme Manager, Presentation, Audience) explicitly check
anchor.status === 'COMPLETED'and render a dedicated “Session Ended” screen — previously they only checked whether the anchor/payload existed at all, so once a session legitimately ended,currentSlot(looked up byanchor.currentSlotPosition) still resolved fine and every view kept showing the ordinary live stage with no active slot to display, reading as a stuck/broken UI rather than a finished session. -
Starting a session late (after its
ServiceSlot.startTimehas passed) has never been restricted —ServiceSessionService.start()has no time-window check, so any DRAFT programme with slots can be started at any time viaPOST /service-session/programme/:programmeId/start. -
A concurrent double-start of the same programme now surfaces the same friendly
ConflictExceptionas the “still live” pre-check, instead of a raw driver error.assertProgrammeIsDrafttakes no row lock, so two near-simultaneousstart()calls for the same programme (a double-tap on the button, or the auto-start scheduler racing a manual start) can both pass it; the DB’sservice_sessions_programme_id_keyunique constraint is the real backstop that prevents an actual duplicateLIVEsession, but the loser previously got an unhandledQueryFailedErrorstraight from the driver. Themanager.save(ServiceSession, ...)call is now wrapped the same wayAttendanceService.checkin()already handles its own unique-constraint race: catch, checkdriverError.code === '23505', throwConflictException('A service session for this programme was just started — refresh and try again'); anything else rethrows unchanged. -
A DRAFT programme that was created but never started can be permanently deleted via the pre-existing
DELETE /service-programme/:id(blocked once a programme leaves DRAFT). The admin Programmes list now surfaces this directly on each DRAFT row (a small trash icon, previously only reachable from inside the detail panel) so an abandoned programme can be removed from the “ready to start” list without opening it first. -
When the session ends, remaining PENDING slots are marked SKIPPED, the programme status moves to COMPLETED, and if
saveAsTemplate = truethe programme is auto-saved as aServiceProgrammeTemplate. A session-report email is fire-and-forget dispatched to all active Admin department workers via Bull queue (template:service-session-report). -
Indexes (migration
AddServiceProgrammeQueryIndexes):service_sessions(status)backs the frequently-polledgetActiveSessions()(global Live pill, every 20s from every open admin tab);service_programme_slots(member_id)backs the synchronous double-booking conflict check run on every slot assignment;service_programmes(status)backs the daily reminder scheduler’s DRAFT filter; a partial index onservice_programme_slots(reminder_sent_at) WHERE reminder_sent_at IS NULLmatches that scheduler’s exact predicate and stays small regardless of table growth.service_slots(start_time)/(end_time)(pre-existing) already cover the conflict check’s time-overlap comparison and the reminder scheduler’s 24–48h window. -
Create Programme dashboard — the admin “New Programme” flow (
CreateProgrammeDashboardinapp/service-programme/page.tsx) replaced a plain “pick one slot, create an empty draft, add items one at a time afterward” modal. The entry point is an Event picker, not a slot picker — service slots are only ever a sub-part of an event, so making the admin pick one arbitrary slot just to “unlock” the rest was the wrong mental model. The dropdown lists distinct events (deduped from the slot list client-side, dated by their earliest slot’s start time, events where every slot already has a programme excluded), and picking one loads all of that event’s slots into a full-width dashboard: a left-hand list of the event’s services (each independently checked on/off, with its own item count/duration, the first not-yet-programmed one auto-selected), and a right-hand editor for whichever service is selected — each service’s order-of-service is a genuinely separate list (no shared/master list, per explicit product direction), built with real HTML5 drag-and-drop reordering (matching the pattern already used for reordering an existing DRAFT programme’s slots) plus the existing move-up/down buttons for accessibility. Submitting maps each checked service to oneprogrammes[]entry in thePOST /service-programmecall above.- Item entry is inline, not modal-based (
ItemEditorRow) — the initial version reused the old full-screenAddSlotModal/EditSlotModal(originally built for adding/editing a single slot on an already-created programme) for this dashboard too, which turned out to be too many clicks per item for building a whole order-of-service in one sitting. It was replaced with an always-visible “quick add” row at the end of each service’s item list — type, topic, duration, and a single merged speaker field (seeSpeakerInputbelow) editable directly in place; pressing Enter or the check button appends the item and immediately resets the row for the next one, with no modal open/close cycle. Clicking an existing item turns that row into the same inline editor (pre-filled, Save/Cancel) instead of reopening a modal. ProgrammeDetailPanel(editing an already-created DRAFT programme) now uses the sameItemEditorRowinstead ofAddSlotModal/EditSlotModal, which have been deleted — editing a programme days after creating it now has the exact same inline, no-modal feel as building it the first time, instead of two different UIs for the same data depending on when you touch it.slotToEditorValue()converts the live APIServiceProgrammeSlotshape (member/backupMemberas nested{id, firstname, lastname}objects) into the sharedItemEditorValuethe row edits; committing calls the realaddSlot/updateSlotendpoints directly (no local draft array — each commit is its own API round trip, unlike the creation dashboard which batches everything into onePOST /service-programmecall). Topic and speaker quick-pick suggestions are drawn from the programme’s own other slots rather than the whole event, since this panel only ever has one programme’s slots in scope.SpeakerInputmerges the old Member/Guest toggle into one field: typing is treated as a guest name by default, and picking a live-search match upgrades it to a member — removing the extra “which kind of person” click before you could even start typing. A backup speaker toggle (“+ Add backup speaker”) is available on each row in this dashboard, using the same mergedSpeakerInput.- Item type is inferred, not chosen — the old type
<select>duplicated the title (“Praise & Worship” twice).ItemEditorRownow has one title field (datalist: titles already used in the event, thenCOMMON_PROGRAMME_ITEMS) and a small icon button (SlotTypePicker).inferSlotType(title)(components/service-programme/slot-type-config.tsx, keyword match, first hit wins: break → dedication → offering → announcement → prayer → worship → speaker) setstypeas you type, falling back toOTHER; picking a type on the icon setstypeLocked, and an existing item whose stored type differs from what its title implies opens locked, so editing never silently re-categorises it.typeis still stored and sent exactly as before — no API change — and still drives the icon/colour in both apps, the PDF label fallback and history’s “by type” breakdown. Slot cards show the badge icon-only when there is a title, so the label isn’t repeated. - Topic and speaker fields autocomplete/suggest from names already used anywhere else in the same event (
topicSuggestions/memberSuggestions, derived client-side from all services’ in-progress items — not persisted, not an API concern), so a repeated item (e.g. “Praise & Worship”, the same worship leader) doesn’t have to be retyped per service. - “Copy from…” in the active service’s header lets you duplicate another already-configured service’s full item list (including backups) into the current one in one action — the order of service is usually similar across a multi-service Sunday even though the ministers differ, so this is duplicate-then-edit rather than a shared/master list (each service’s items stay fully independent once copied; editing one afterward never affects the other). Prompts for confirmation only if the target service already has items, since that copy would overwrite them.
- “Apply template…” sits next to “Copy from…” in the same header — applying a saved
ServiceProgrammeTemplate(previously only usable viaapplyTemplate()on an already-created programme, a second trip after creation) now populates the active service’s local draft list directly at creation time, client-side, the same way “Copy from…” does (templateSlotToDraftItem()converts the template’sServiceProgrammeSlot[]into the localDraftItem[]shape). Same overwrite-confirmation rule as “Copy from…”.applyTemplate()/ApplyTemplateModalare unchanged and still available on an already-created programme viaProgrammeDetailPanel’s “Template” button — this is an additional, earlier entry point, not a replacement. DraftItemRow’s secondary line (speaker/duration) uses each slot type’scfg.textcolour (e.g.text-amber-800for Speaker) instead of a flat grey — that per-type colour token existed inSLOT_TYPE_CONFIGalready but was unused; pairing it with the matchingcfg.bgtint (e.g.bg-amber-50) gives correct, type-appropriate contrast instead of one grey that read as low-contrast against every row colour.- “My Upcoming Assignments” — previously a member/worker’s only signal that they were scheduled was the one-off
service-slot-assignedemail; there was no way to look it up later.GET /service-programme/my-assignments(getMyUpcomingAssignments()inServiceProgrammeService) fixes this on the read side: any authenticated member/worker can pull their own upcoming slots — as primary or backup — across every not-yet-completed programme. This is admin-portal-agnostic (JwtAuthGuardonly, no admin permission), consumed by the member-facing app (discuva-member, a separate Next.js PWA from the admin portaldiscuva-admin) rather than the admin dashboard —hooks/use-my-assignments.tsthere polls it every 30s (usePollingEffect, same visibility-aware pause/catch-up behavioruseMyLiveStatususes) andcomponents/layout/home.tsxrenders a horizontally-scrolling “My Upcoming Assignments” card row on the member home screen (dark cards matching the existing hero’s palette), shown only when the member actually has something coming up. The hook originally fetched once on mount only — a member who opened Home before their assigned service’s session wentLIVEand simply left the app open never saw the card flip into its tappable/countdown-eligible state (theisLivecheck depends onprogrammeStatus/sessionCodefrom this same response), since nothing ever re-fetched it; polling fixes that. - Real-time “my slot” view + personal service history — two more member-facing additions alongside “My Upcoming Assignments” in
discuva-member: (1) once a member’s upcoming assignment’s programme goes LIVE, its card becomes tappable (pulsing “Live” badge) and links to/my-assignment/:sessionCode, a page backed byGET /service-session/:sessionCode/my-status(getMyLiveStatus()) — shows a live countdown to their turn, an “you’re up now” banner once it arrives, an “your part is complete” state afterward, and the full running order with their own row highlighted; the countdown ticks locally client-side between 8s polls using the samefetchedAt+ elapsed-time technique the admin Live Session Dashboard already uses, rather than polling more aggressively. (2)/service-history(hooks/use-my-service-history.ts→GET /service-session/my-history) — a paginated list of the member’s own completed slots with total time served and a per-slot-type breakdown, linked from Profile’s general Explore section (not worker-gated —ServiceProgrammeSlot.memberhas no role restriction, so a plain member assigned a slot has just as much reason to see this as a worker; the frontend tile-visibility gate was the only thing that had ever restricted it,getMyServiceHistoryitself never did). Both reuse existing server-side logic rather than introducing new authorization concepts:getMyLiveStatusnever exposes other members’ identities (only role/position/timing derived values), andgetMyServiceHistory’s effective-speaker crediting rule is identical togetAnalytics’smemberIdfilter, so the two can never disagree about who gets credit for a slot.getMyServiceHistoryalso includes a LIVE session’s already-completed slots, not just fully-COMPLETED sessions — a slot’s own status flips toCOMPLETED(withactualSecondsset) the moment it’s advanced past, well before the session as a whole is ended, so history reflects that immediately rather than waiting for someone to end the whole session; the per-slotCOMPLETEDfilter this relies on also incidentally excludesSKIPPEDslots (whichend()produces for anything stillPENDINGwhen a session is ended early) from ever surfacing as if they’d been performed. - General order-of-service view + Front Desk session control — two more member-facing additions in
discuva-member, both reusingGET /service-programme/upcoming(getUpcomingForMembers()inServiceProgrammeService— the soonest programme that’sLIVE, orDRAFTwith a still-futureserviceSlot.startTime; aLIVEprogramme always qualifies regardless of its original scheduled time, so a service running long doesn’t vanish from view just because the clock passed its start. Returnsnull, never a 404, when nothing qualifies — “no service scheduled right now” is a normal state. Slots map tospeakerName/backupSpeakerNamestrings only, never the rawMemberrow, since — unlikemy-assignments/adminfindOne— this is returned to any authenticated member, not just whoever has a slot in it): (1)/order-of-service— a read-only, printed-programme-style listing of the whole lineup (every member, not just those with a slot in it), the answer to “what’s the order of service this week.” (2)/front-desk-session, gated behind the sameFRONT_DESK_OPERATIONScapability tile as “Check Someone In” — a scoped-down live-control surface (start / back / next / pause+reason / resume / end, polling the same publicGET /service-session/:code/statethe audience/PM display views use) built for a front-desk worker running the room week to week, not the full producer toolkit (reordering live slots, arbitrary time adjustment, and overriding a slot’s speaker stay reachable only via the admin Live Session Dashboard or the public Programme Manager link). - Department slots (whole teams) — a slot (and separately its backup) can be given to a
Departmentinstead of a member or guest:service_programme_slots.department_id/backup_department_id(SET NULL, partial indexes), DTOdepartmentId/backupDepartmentId. A department can’t share a slot with a person (400); on update, settingdepartmentIdclears the member/guest and setting a member/guest clears the department. Membership = active worker profiles whose primary or secondary department it is (DepartmentAccessService.findMemberIdsInDepartment); the contact is the department’s HOD (findHeadOfDepartment). Notifications: one push (SERVICE_SLOT_TEAM_ASSIGNED) to every member plus the HOD, and theservice-slot-assignedemail to the HOD only — so a 40-person choir never gets 40 emails. Reminders: the day-before scheduler now goes throughNotificationDispatchServiceand adds aSERVICE_SLOT_REMINDERpush for individual slots; for department slots it pushes the whole department and emails the HOD (with the.ics). Member reads:my-assignmentsmatches department slots viafindDepartmentIdsForMemberand returnsasDepartment;:sessionCode/my-statusmatches through the department (only while nobody has been put in its place on the day) and returnsasDepartment;my-historyincludes the department’s completed slots (asDepartmenton each entry) and abyDepartmentrollup (count,totalActualSeconds,totalOverrunSeconds) — the team’s performance. Display:speakerName/EffectiveSessionSlot.departmentName/ PDFs / session report fall back to the department name. Templates keepdepartmentIdper slot (people are still not kept) and applying one notifies the department. - Analytics tab — a third tab alongside Programmes/Templates (
AnalyticsTabinapp/service-programme/page.tsx) surfacesGET /service-session/analytics, which existed on the backend fully built but had no frontend caller before this. Filterable by date range and service slot name; renders summary cards (completed sessions, avg completion rate, total overrun slots, total pause time — all derived client-side from thesessionsarray) plus three tables: per-slot-type stats (avg actual vs. allocated time, overrun counts), top speakers (by total/avg time on the mic), and recent completed sessions. Gives an admin running several services a week visibility into load-balancing and pacing without opening individual session reports one at a time. Defaults to a bounded ~180-day lookback (ServiceSessionService.defaultAnalyticsFrom()) whenfromis omitted, instead of the 6-way-joined query scanning every COMPLETED session ever recorded — the tab’s ownfrom/toinputs are pre-populated with this same 180-day window on first load so the shown range is never silently narrower than what the UI displays; an explicitfrom(however old) is always honored as-is.- Fixed: the “Service Slot Name” (and date-range) filters silently did nothing —
load()was wrapped inuseCallback(..., [fetchAnalytics]), missingfrom/to/serviceSlotNamefrom its dependency array, so the memoized closure always read the empty strings captured on first render regardless of what was typed. Fixed by including them in the deps; the initial-mount fetch now runs from a plainuseEffect(() => { load(); }, [])instead of depending onloaditself, so typing a filter doesn’t trigger a fetch on every keystroke — only clicking “Load” (onClick={load}) does, now with the current input values. - Fixed (backend): even once the frontend closure bug was fixed, the filter still only matched a session’s sub-service label (
serviceSlot.name, e.g. “First Service”) — typing the service’s actual name (the parent Event’s name, e.g. “Sunday Service”) matched nothing, sinceeventwas never joined into the analytics query at all.fetchAnalytics()now joinsserviceSlot.eventand matches(serviceSlot.name ILIKE :name OR event.name ILIKE :name), so either name works. Frontend field relabelled “Service Slot Name” → “Service Name” to match. - The “Service Name” field suggests as you type via
ServiceNameFilterInput, matchingSearchableSelect’s visual pattern (the same one the service-headcount page’s slot pickers use) rather than a native<datalist>— a search-icon input, a dropdown of matches each showing its date as a grey sublabel ({name} — {date}, since the same name recurs across many dates and there’d be no way to tell occurrences apart otherwise), and once a suggestion is clicked, a chip ({name} — {date}+ a clear button) replaces the input, exactly like a selectedSearchableSelectoption. The datalist was tried first but browser-native datalist rendering/filtering is inconsistent enough that it read as broken to users. One deliberate difference fromSearchableSelect: typing without clicking a suggestion still updates the filter value (clearing any previously-selected chip back to free-text mode) rather than requiring an exact pick, since the backend does a partial/ILIKE match onserviceSlotNameand the field needs to stay usable for names or occurrences that aren’t in the (client-side, non-exhaustive) suggestion list. The filtering itself is a local substring match — no API round-trip, unlike the Minister/Speaker filter which searches live. - Fixed: suggestions were sourced from the hook’s
fetchServiceSlots()— built for the “Create Programme” picker, so it deliberately filters to future events only and excludes any slot that already has a programme. Analytics needs the opposite (names of past/completed sessions), and since a slot only shows up in analytics once its programme has actually run, that filter combination excluded essentially every real name, leaving the suggestion list empty regardless of the input component.AnalyticsTabnow fetchesGET /events?page=1&limit=200directly (noupcomingparam — that filter is opt-in and off by default) and collects every distinct event/service-slot name with no date or usage filtering, dropping its dependency on the sharedhookprop entirely (<AnalyticsTab />takes none now). - Minister/Speaker filter —
MemberFilterInput(new, inapp/service-programme/page.tsx) is the same live/members?search=combobox asSpeakerInputused at creation time, minus the guest-name fallback (this is a filter, not an assignment field). Backend-side,memberIdrestricts the query to sessions this member actually appeared in via a session-slot subquery (session.id IN (SELECT ... service_session_slots ... WHERE ps.member_id = :memberId OR ss.overridden_member_id = :memberId)) — a raw SQL subquery rather than a plain join-then-filter, because filtering on a left-joinedsessionSlotsrelation directly would silently truncate that session’s OTHER slots out of the hydrated result (corruptingcompletionRate, which depends on the session’s full slot count). Within an included session, the per-slot accumulation loop also skips slots that aren’t this member’s, soslotTypeStats/topSpeakersreflect only their own contribution — whilecompletionRate/totalDurationMinutesstay session-wide (those describe the whole service, not one person’s slice of it). - Slot Type filter — a
<select>of the 8ServiceSlotTypeEnumvalues (reusingSLOT_TYPES/SLOT_TYPE_CONFIG, already defined in this file for the programme editor). Backend-side,slotTypedoesn’t remove sessions from the list (a service having no “Offering” segment isn’t itself meaningful to filter out) — it only restricts which slots feed intoslotTypeStats/topSpeakers/the per-sessionoverrunSlotscount, so “compare just Offering segments across every service” resolves to a single-row breakdown table instead of scrolling past 7 other types to find it. - Quick date presets (“7d” / “30d” / “This Quarter”) compute the range and fetch immediately on click, rather than just filling the date inputs and waiting for “Load” — since a preset click is already one deliberate action, requiring a second “Load” click after it would be redundant. They call
fetchAnalyticsdirectly with the freshly computed dates instead of going through the memoizedload(), for the same reasonload()itself doesn’t chase its own tail:setFrom/setTodon’t take effect until the next render, so calling the existingload()immediately afterward would still run with the previous (stale) range.
- Fixed: the “Service Slot Name” (and date-range) filters silently did nothing —
- Create Programme’s Event picker is now searchable — was a plain
<select>listing every open event; replaced with the sameSearchableSelectcomponent used by the headcount page’s Event/service-slot pickers (extracted to the sharedcomponents/ui/searchable-select.tsxrather than duplicated, and re-imported into the headcount page too). Each option’s sublabel shows the date and sub-service count, matching the old<option>text. - Full-event report downloads — the Programmes list groups by event already (
groupProgrammesByEvent); each event group’s header now has a “Full Report” button (GET /service-programme/event/:eventId/pdf, always available — the order-of-service across every sub-service regardless of session state) plus a “Session Report” button that only appears once every programme in the group isCOMPLETED(GET /service-session/event/:eventId/report/pdf, the post-service analytics report — timing, pauses, completion rate — which 400s if any session isn’t finished yet, so it’s hidden rather than shown-then-erroring). Both backend routes already existed and were unused by any frontend button before this. Each individual sub-service row also has its own small download icon (“download this service only”,GET /service-programme/:id/pdf) alongside the existing detail-panel download, for a quick single-service PDF without opening the panel. - “Start Service” sequential start — a multi-service Sunday starts one sub-service at a time in slot order (First Service, then Second Service, and so on), not all at once. Each event group’s header shows a “Start
<slot name>” button whenever at least one of its programmes is stillDRAFT, labeled with the earliest not-yet-started slot (getNextDraftProgrammeinpage.tsx, sorted byserviceSlotDetail.startTime). CallingPOST /service-session/event/:eventId/start(startEventSessionsinuse-service-session.ts) starts only that one programme and returns a single session (not an array). The backend rejects the call with 409 if a session for the event is alreadyLIVE— the current slot must be ended (POST /service-session/:sessionCode/end) before the button can start the next one. No new “EventSession” entity was introduced —ServiceSessionService.startEvent()still reuses the existing per-programmestart(), it just now picks the single earliestServiceProgrammeService.findStartableDraftProgrammesForEvent()result (that method sorts byserviceSlot.startTimeASC) instead of looping over all of them. The frontend button is disabled (with an explanatory tooltip) whenever any programme in the event group isLIVE, in addition to the backend’s 409 — so an admin can’t click it mid-service and only sees the error if state is stale.
- Item entry is inline, not modal-based (
Access control:
- Both controllers (
ServiceProgrammeController,ServiceSessionController) carry@RequiresPlan(PlanFeature.SERVICE_PROGRAMME)and@RequiresModule('service_programme')at class level, covering every route including the public share-token ones.service_programmeisrequired: trueinKNOWN_MODULES, soChurchSettingsService.upsertrefuses to let a tenant admin disable it — the module gate is enforced for consistency with every other Pro-plan-gated module (sermon, incident report, volunteer, asset management, facility rental, service ratings), not because it can actually be turned off. - Programme CRUD and reporting:
AdminGuard+SERVICE_PROGRAMME_READ(reads) orSERVICE_PROGRAMME_WRITE(mutations). Assign these permissions to admin roles via the role management API. - Authenticated session control (start, advance, rewind, pause, resume, adjust-time, reorder, end): controller only requires
JwtAuthGuard(any authenticated member or admin); the real rule is enforced once inServiceSessionService.assertCanControlSession()— passes for either of two groups, both individually attributable via their own authenticatedmemberId(no PIN/name workaround needed for either): an activeAdminentity holdingSERVICE_PROGRAMME_WRITE, or a worker in a department with theFRONT_DESK_OPERATIONScapability (checked viaDepartmentAccessService.hasCapability, the same non-throwing boolean checkdiscuva-member’s front-desk control page (app/front-desk-session/) relies on to decide whether to show that tile at all). This reverses an earlier, narrower version of this rule that removed department-based control access entirely (reserved for a mobile worker UI that hadn’t been built yet against these endpoints) — that UI now exists, so the capability check was restored. Anyone in neither group — an external production collaborator with no Discuva account, for instance — still controls a session exclusively through the public Programme Manager link with a named PIN grant (see below). This does not affectgetEventSummaryReportPdfForWorker(GET /service-session/event/:eventId/summary-pdf, a read-only mobile PDF download) — that keeps its own narrowerassertIsAdminDeptWorker()check (Admin-department worker, noSERVICE_PROGRAMME_WRITEfallback needed), deliberately kept separate from session-control access. - Public Programme Manager routes (
POST/PUT /service-session/:code/pm/*— advance, rewind, pause, resume, adjust-time, reorder, end):@Public()+ShareTokenGuard(?token=), andNamedAccessGuard(?grantToken=). The share token only proves “has the link”; every PM-link holder used to be logged as the same genericPUBLIC_LINKactor with no way to tell people apart or revoke one person without rotating the link for everyone.NamedAccessGuardlayers named, individually-revocable identity on top: an admin/worker callsPOST /service-session/:code/access-grants({ name },JwtAuthGuard+assertCanControlSession) to generate a 6-digit PIN for a collaborator (randomInt-generated, argon2-hashed viaUtilityService, returned in plaintext exactly once — never stored or retrievable again). That person then calls the publicPOST /service-session/:code/pm/access(ShareTokenGuardonly — this route establishes identity, so it can’t itself requireNamedAccessGuard) with{ name, pin }; on successverifyAccessGrantissues agrantToken(random UUID, Redis-cached alongside{ grantId, name }, same TTL as the session) that must be appended to every subsequentpm/*write call.NamedAccessGuard.resolveGrantTokenresolves that token, re-checks the underlyingServiceSessionAccessGrantrow’srevokedAton every call (not just at sign-in), and stamps the grant’s name onto the request (@ActorLabel()) so it flows through tologAction’sactorLabelcolumn — surfaced as theactorNamefallback ingetActionLog/getActionLogCsvwhenever there’s noperformedByMember. Revoking viaPOST /service-session/:code/access-grants/:grantId/revoketakes effect on that person’s very next action, without touching the shared link or anyone else’s grant. Grants are scoped to a single session (tableservice_session_access_grants,session_idFKON DELETE CASCADE) — a new PIN is needed each time someone needs PM access to a new session, by design (no cross-session standing identity to manage). The frontend Live Session Dashboard’s “Programme Manager Access” panel manages grants (add/list/revoke, showing the PIN once); the public/live/:code/manageview gates itself behind a name+PIN sign-in form the first time, then caches the resultinggrantTokeninlocalStorage(keyed per session) so it isn’t re-prompted on every visit, clearing that cache automatically if any action comes back with a revoked/expired-access error. - Duplicate active names are rejected, not silently allowed — two active grants sharing a name make sign-in ambiguous (the name+PIN lookup could match whichever row it finds first, so a correct PIN for the second grant could get rejected).
generateAccessGrantpre-checks for a non-revoked grant with the same name (trimmed, case-insensitive) and throwsConflictException(409) instead of creating the duplicate; a partial unique index (uq_service_session_access_grants_session_name_activeon(session_id, lower(name)) WHERE revoked_at IS NULL, migrationAddServiceSessionAccessGrantUniqueActiveName) catches the same race at the DB level as a safety net, translated back into the same 409. The caller can passreplaceExisting: trueonPOST /service-session/:code/access-grantsto confirm the swap — this revokes the old grant (logged asACCESS_GRANT_REPLACED) and issues a fresh PIN under the same name in one call. The Dashboard’s “Programme Manager Access” panel surfaces this as a “Replace?” prompt when adding a name that’s already active, rather than a bare error. overrideSlot(speaker runtime override) staysRolesGuard (WORKER)+FRONT_DESK_OPERATIONScapability check only — not exposed in the admin dashboard.- Session state read (
GET /service-session/:code/state) and speaker slot view (GET /service-session/:code/slots/:position):@Public()— session code is the access credential. (These are read-only; the share token, not the session code, gates writes.) ADMIN_WRITEpermission controls who can assignSERVICE_PROGRAMME_READ/SERVICE_PROGRAMME_WRITEto admin roles.
WebSocket:
- Namespace:
/service-session— this is the primary live-update channel for all four frontend session views (see the “Live updates moved from per-client polling to Socket.IO push” bullet above); each view keeps only a slow 30s safety-net poll of the REST routes as a fallback. - Client event
joinSession({ sessionCode })→ validates the session exists (callsgetState()) before joining roomsession:{sessionCode}; on failure the client is not joined and receivessession:error({ message }) instead. - Client event
leaveSession({ sessionCode })→ leaves room - Server event
session:state→ the fullSessionStatePayload({ anchor, session, effectiveSlots, cautionThresholdRatio }) — the same shapeGET /service-session/:code/statereturns, emitted after every mutation (advance/rewind/pause/resume/adjustTime/reorderLiveSlots/overrideSlot/end, both authenticated andpm/*variants). - Server event
session:error→{ message: string }, emitted only on a failedjoinSession. - No authentication is required to join a room — session code is the read credential, matching the trust model of the public REST routes this channel replaces for live viewers. No write actions happen over the socket.
- Redis adapter:
@socket.io/redis-adapter(RedisIoAdapter) is used instead of the default in-memory adapter. Events broadcast on one backend instance are forwarded to all other instances via Redis pub/sub, making horizontal scaling safe. - CORS: validated via
createCorsOriginValidator()(same shared validator as the HTTP API’sapp.enableCors()), not a wide-openorigin: '*'.
Routes prefix: /service-programme, /service-session
Games Module
Kahoot-style live quiz for member engagement. A Game (title, description, optional department/churchClass for
admin-side categorization/reporting only — not access control) holds an ordered list of GameQuestions and is a
reusable definition that can be run multiple times via a GameSession. Games can only be created/edited on the admin
portal (GAMES_WRITE); anyone with a session’s join code can participate — no department/class/role gating on the
participant side, by explicit product decision.
Game lifecycle: Game.status tracks whether it’s still being edited (DRAFT) or currently backing a live session
(LIVE_SESSION_ACTIVE) — it’s informational, not a gate; admins can always edit a DRAFT game’s questions.
startSession requires at least one question and flips the game to LIVE_SESSION_ACTIVE; endSession reverts it to
DRAFT — but only if no other session for the game is still LIVE (see below), so the same game can be started
again later. startSession also 400s if a LIVE session already exists for the game — previously a double-click or
a second admin starting the same game would silently orphan the first session (its join code still worked, but
nothing in the UI could get back to it).
Game.status is a denormalized mirror of “does this game have a live session”, and — as the guard above and the
defensive check in endSession both exist to address — it can drift out of sync with reality (e.g. a session left
LIVE from before either of those existed, or any future code path that touches a session without going through
this service). listGames/getGame therefore return an activeSessionCode field sourced directly from
GameSession (querying every LIVE session for the games in the batch), not gated by Game.status === LIVE_SESSION_ACTIVE — so the admin portal’s “Resume Control” action stays correct and discoverable even for a game
whose status wrongly says DRAFT while a session is still actually running underneath it. endSession mirrors
this: before resetting Game.status to DRAFT, it re-checks for any other still-LIVE session for the same game
and skips the reset if one exists, so ending one orphan can’t stomp on a genuinely-live sibling session.
Same batch call also attaches playCount — a count of each game’s ENDED sessions, grouped in one query rather
than N+1. Game.status reverting to DRAFT after every session ends (not to some distinct “played” state) meant
the admin list showed the identical “Draft” label for a game that had genuinely never been touched and one that had
just been run ten times — playCount is what actually distinguishes those two cases in the UI (see the
gameStatusDisplay note under discuva-admin below), not a change to Game.status itself.
Session lifecycle, with a real lobby: GameSession.sessionCode (GAME-XXXXXX, same random-alphanumeric
generation as ServiceSession.sessionCode) is the join credential — no auth beyond being a logged-in member/worker is
required to join. startSession creates the session LIVE but with currentQuestionIndex/currentQuestionStartedAt
both null — this is the lobby: members can join and see the code, but no question’s timer starts until the host
explicitly reveals Question 1. Previously startSession set currentQuestionIndex = 0 and stamped the timer
immediately, meaning Question 1’s clock started the instant the button was clicked, before any member could possibly
have joined. nextQuestion’s (currentQuestionIndex ?? -1) + 1 already handled the null → 0 transition correctly
with no other change needed — the fix was purely in what startSession writes. nextQuestion 400s if already on
the last question (call endSession instead). hostAdmin is recorded at start — only that admin (or any admin if
hostAdmin was somehow cleared) can advance the session via nextQuestion (ForbiddenException otherwise).
endSession is deliberately NOT host-restricted — it’s the safety valve for a session whose host closed their
tab without ending it themselves (the only way to clear a game stuck LIVE, since startSession blocks starting a
new one while any session for the game is still live); any admin with GAMES_WRITE can end one, with the actual
actor still traceable via the GAME_SESSION_ENDED audit log entry. endSession itself remains idempotent (a second
call is a no-op, not an error).
Countdown (GameSessionStatePayload.currentQuestionStartedAt): the payload carries the current question’s start
time as epoch ms alongside the existing secondsRemaining snapshot. secondsRemaining is only accurate as of the
moment the payload was generated — a client that just renders it verbatim sees the countdown freeze between socket
broadcasts (previously the only source of updates: nextQuestion, endSession, or the 30s safety poll) instead of
ticking down every second. currentQuestionStartedAt lets a client compute its own live countdown
(timeLimitSeconds - (Date.now() - currentQuestionStartedAt) / 1000, ticked with a 1s setInterval), the same
pattern ServiceSessionController’s anchor.slotStartedAt already uses for the service-programme timer.
Scoring (GameService.computeScore): speed-bonus model — pointsAwarded = isCorrect ? round(question.points * max(0.5, remainingTimeFraction)) : 0, where remainingTimeFraction is computed from currentQuestionStartedAt (a
server-side clock all participants are scored against, not each client’s own page-load time) versus the question’s
timeLimitSeconds. An instant correct answer earns full points; a correct answer submitted right at the deadline
earns 50% of the question’s points; any incorrect answer earns 0. GameParticipant.totalScore is a running total,
incremented per response — the leaderboard itself is always computed live from GameResponse rows
(SUM(pointsAwarded) effectively, via totalScore and a ORDER BY totalScore DESC read), not a separately-audited
aggregate.
Answer submission (POST .../answer) is a normal REST call, not a socket message — matches this codebase’s
discipline of keeping every scored/audited action behind the guard+validation layer. It’s rejected (400) if the
session isn’t LIVE, if the question isn’t the session’s current question (guards against a stale client
answering a question that’s already advanced past), if the participant already answered it (also enforced at the
DB level via a unique constraint on (session_id, question_id, participant_id) — the pre-check leaves a race window
under a concurrent double-submit, so submitAnswer also catches that constraint’s violation (Postgres 23505) and
returns the same 400 rather than letting a raw DB conflict surface as a 500), or if it’s past the time limit —
ANSWER_GRACE_SECONDS (2s) of slack past question.timeLimitSeconds, measured from currentQuestionStartedAt.
Previously there was no server-side time check at all — a late answer (while the question was still current) just
scored at the MIN_SPEED_BONUS_FRACTION floor rather than being rejected, so answering was silently unbounded as
long as the host hadn’t advanced yet. The grace window exists because clients count down locally from
currentQuestionStartedAt, so a click registered right at 0s legitimately lands at the server a beat later on
network/render time. 403 if the caller never called join first.
What participants never see: GameSessionStatePayload (both the REST GET .../state response and the
session:state socket broadcast) never includes correctOptionIndex — the admin presenter view, which does need to
know the answer while presenting, already has the full question (with the answer) from its own authenticated
GET admin/games/:id/questions fetch, so the shared broadcast payload stays participant-safe without needing two
different payload shapes for the same event.
Routes (admin, AdminGuard):
POST/GET/PATCH/DELETE admin/games(/:idfor single-record routes) —GAMES_READ/GAMES_WRITEGET/POST admin/games/:id/questions,PUT admin/games/:id/questions/reorder,PATCH/DELETE admin/games/questions/:questionId—GAMES_READ/GAMES_WRITEPOST admin/games/:id/start,POST admin/games/sessions/:code/next-question,POST admin/games/sessions/:code/end—GAMES_WRITEGET admin/games/sessions/:code/state,GET admin/games/sessions/:code/leaderboard—GAMES_READGET admin/games/:id/sessions?page=&limit=—GAMES_READ. Past (and current, LIVE included) sessions for a game —GameSessionSummary[]:sessionCode,status,startedAt,endedAt,participantCount,topScore,topScorerName. The session data already fully persisted (GameParticipant.totalScore,GameResponse’s per-answer audit trail) — this closes the actual gap, which was discoverability: no way to find a session’s code again once you’d navigated away from wherever it was started.topScore/topScorerNamecome from aDISTINCT ON (session_id)query orderedsession_id ASC, total_score DESC, created_at ASC— thecreated_attie-break matters, since without it Postgres’s pick among equal top scores is arbitrary and “the winner” would flicker between requests. IncludingLIVEsessions (not justENDED) means this list doubles as the “resume/end a stuck session” surface for a game whose host closed their tab without ending it.
Note: games/sessions/:code/state on the participant controller below is @Public(), not JwtAuthGuard-gated.
Routes (participant, JwtAuthGuard + @RequiresModule('games')):
POST games/sessions/:code/join,POST games/sessions/:code/questions/:questionId/answer,GET games/sessions/:code/leaderboard— any authenticated member/worker, no department/class/role gatingGET games/my-history?page=&limit=—MyGameHistoryEntry[]:sessionCode,gameTitle,playedAt,totalScore,rank,participantCount,correctCount,answeredCount.ENDEDsessions only — a mid-flightLIVEscore isn’t a result yet, and its rank could still change.rankcomes from a windowedRANK() OVER (PARTITION BY session_id ORDER BY total_score DESC)raw query (notROW_NUMBER()— tied scores must share a rank rather than being arbitrarily split), with the calling member’s own filter applied outside the window; filtering inside it would make the window see only that member’s single row and always return rank 1.GET games/sessions/:code/state—@Public()(still behindModuleEnabledGuardand a300/60sthrottle, same shape asServiceSessionController’s public:sessionCode/state), so a projector/second-laptop presentation screen can poll it with just the join code — no member/admin login needed. Never leakscorrectOptionIndex(see above), so widening this one route to unauthenticated access doesn’t change what it exposes.
WebSocket:
- Namespace:
/game-session—joinSession({ sessionCode })/leaveSession({ sessionCode })client events, roomgame-session:{tenantId}:{sessionCode}, server eventsession:statecarrying the fullGameSessionStatePayload, emitted by the controller (not the service, same split asServiceSessionController/ServiceSessionGateway) after every mutating admin or participant action. - Bug fix: this gateway never actually worked.
TenantMiddlewareis applied via.forRoutes('*')(tenant.module.ts), which is HTTP-route-only and never runs for WebSocket messages — sohandleJoin’s call intoGameServicehad no CLS tenant context, no schema resolved, and every tenant-scoped repository query silently fell through to whatever the default connection resolved to (public.game_sessionsis a dead legacy table from an early migration), meaninggetSessionOrThrowalways threw and every join silently failed. Neither client noticed: discuva-admin’s socket hook treatsconnected: trueas a signal to disable its own polling safety net, and nothing listened forsession:error— so the presenter panel and projector screen have been running on re-fetch-on-action alone this whole time, never actually receiving a live push. Fixed with a realhandleConnection: resolves tenant fromclient.handshake.auth.token(a JWT, which already carries bothtenantIdandschemaNameas claims — verified against the access secret, then the refresh secret, same two-try pattern asServiceSessionGateway.verifyTenantClaim) for an authenticated client, or fromclient.handshake.auth.tenantSubdomainvia a plain public-schemaTenantrepo lookup for the unauthenticated projector screen (which has no JWT at all). The resolved{tenantId, schemaName}is stored onclient.data, andhandleJoinwraps itsGameService.getSessionStatecall inrunInTenantContext(tenant/utility/run-in-tenant-context.ts— the same helper Bull processors use to re-enter tenant context outside an HTTP request) rather than calling it bare.broadcastStatereadstenantIdoffClsServicedirectly, since it’s always invoked from inside the HTTP request that just performed the mutation. The room key gained atenantIdsegment as a side effect —sessionCodeonly has a per-schema unique index, not a cross-tenant one, so without it two different churches could theoretically collide onto the same room. - No authentication required to join beyond a resolvable tenant — session code is the read credential. Answer submission itself never happens over the socket (see above).
joinSessionreports its outcome via a Socket.IO acknowledgment, not a separate emitted event. Two patches were tried and superseded before landing here, both worth naming since either could resurface as a regression: (1) originally,connectedflippedtrueon the bare transportconnectevent — wrong, sinceconnectfires even when the follow-up join is then rejected server-side, so a client with no resolvable tenant looked “connected” while never actually receiving anything. (2) Fixed by flippingconnectedonly on actually receiving asession:statepush, withhandleJoinemitting the state it had already fetched (to validate the session exists) back to the joining client — but a session with nothing yet mutated since the join (a fresh, still-empty lobby, the common case) triggers nobroadcastStatefrom anywhere else either, so that client got no event at all: not an error, not a state push, stuck indefinitely on “Reconnecting…” despite the join having actually worked. Both patches shared the same root flaw — inferring “did my join succeed” from a separately listened-for event that may or may not correlate with this specific attempt. The real fix:handleJoinnow returns its result directly (Promise<{ok: true, state} | {ok: false, message}>) rather than emitting anything itself; Nest’sWsAdaptersends a handler’s return value back as the argument of whichever callback the client passed to its ownsocket.emit('joinSession', payload, callback)call — Socket.IO’s acknowledgment mechanism, built for exactly this “did this one request succeed” question. Both client hooks now readconnectedstraight off that ack, with no separate event to miss.session:errorno longer exists as an emitted event at all — every failure mode a client needs now arrives as{ok: false, message}on the same ack the success case uses.
Routes prefix: /admin/games, /games
Admin frontend UX (discuva-admin): mirrors the service-programme live-session split between a control surface
and a screen-safe display, rather than the one page both were previously crammed into. app/games/present/[code]
(withAuth, now games:read — was games:write, relaxed since this page doubles as the read-only results view for
a past session, see below) is the host’s control panel — question preview, the ticking countdown, Next Question/End
Session, leaderboard — plus a “Copy Link”/“Open Screen” action for the presentation view. The Next Question/End
Session controls themselves stay gated on hasPermission("games:write") client-side, so a read-only admin sees
everything but can’t act on it. That view lives at app/games-screen/[code] (outside app/games/, so it isn’t
wrapped in app/games/layout.tsx’s admin Shell chrome) and is unauthenticated, full-bleed, dark-themed, big-type —
built to be opened on a projector or a second laptop via the copied link, same pattern as /live/[code]/presentation.
Both pages tick a local setInterval(() => setNowMs(Date.now()), 1000) and derive secondsRemaining from
currentQuestionStartedAt via calcGameSecondsRemaining() (hooks/use-games.ts) instead of rendering the payload’s
secondsRemaining snapshot directly, fixing a bug where the on-screen countdown only changed when a broadcast
arrived instead of counting down every second. The games list (app/games/page.tsx) and detail
(app/games/[id]/page.tsx) pages surface a “Resume Control”/“Resume Live Session” action off the new
activeSessionCode field for any LIVE_SESSION_ACTIVE game — and, alongside it, an “End” action (calling the same
now-non-host-restricted POST .../end endpoint) so a session left LIVE by a host who closed their tab without
ending it can be cleared without needing to know its code.
Status label and a direct “Start” action on the games list (app/games/page.tsx). Two related fixes:
Game.statusreverting toDRAFTafter every session ends (not to some distinct “played” state) meant a game that had been run ten times and one that had never been touched both showed the identical “Draft” badge —gameStatusDisplay()now reads the newplayCountfield alongsideisLiveto show “Draft” only for a game with zeroENDEDsessions, “Ready” for one that’s been played before and is currently idle, and “Live” as before. A “Played” column (N×, or—at zero) sits next to it.- Starting a session previously required going through the row’s “Questions” link — labeled and built as the
question editor, with “Start Live Session” tucked into that page’s header — so the only path to actually launching
a game read as “go edit questions” first. A “Start” button now sits directly on the list row (next to “Questions”,
shown whenever the game isn’t already live), calling
POST admin/games/:id/startand navigating straight to the control panel on success — the exact same requestapp/games/[id]/page.tsx’s own button already made, just reachable without the detour. No question-count pre-check was added to gate the button — the backend’s own 400 (“Add at least one question before starting a session”) surfaces as a toast on failure instead, the same pattern the adjacent “End” action already uses (which also gained toast error surfacing here, having previously failed silently).
Bug fix: the presentation screen never actually loaded, for any tenant, ever. app/games-screen/[code] is
deliberately public (no login, so it can run unattended on a projector) — but every API call from discuva-admin
flows through a shared axios client whose base URL is computed from window.location.hostname, and this app is a
single shared host in production with no per-tenant subdomain of its own (tenant normally comes from the logged-in
admin’s JWT instead — see utils/tenant/api-base-url.ts’s own comment). A public route has no JWT and no subdomain,
so it had no way to identify which church’s session to look up — confirmed via a direct curl of the underlying
public API endpoint, which 404s {"message":"Tenant not found"} without a tenant header and returns full data with
one. Fixed with a dedicated axios instance (utils/games/screen-api.ts, withCredentials: false, no Authorization
interceptor) rather than a flag on the shared client — the shared client’s cookie/JWT would otherwise silently win
over any override on the exact machine most likely to test the link (the host’s own logged-in browser), per the
backend’s tenant-resolution precedence. The tenant subdomain travels as a ?t= query param on the link
app/games/present/[code] generates (now reading subdomain off GET /tenant/info, a genuinely reliable
client-side “which tenant is this” source, rather than the pre-existing localStorage-remembered value from the
login form, which the codebase’s own comments already flagged as best-effort-only).
Bug fix: the socket layer these two pages both use had never actually worked — see the WebSocket section above
for the full history (two superseded patches before landing on Socket.IO acknowledgments). hooks/use-game-session-socket.ts
now derives connected from the joinSession call’s own ack callback — socket.emit('joinSession', {sessionCode}, (ack) => {...}) — rather than any separately listened-for event, so there’s no “did I miss the event” gap to have
regardless of whether the room is busy or completely quiet. Both pages’ 30s safety poll stays gated on !connected
exactly as before; it just now reflects reality correctly. auth: { token, tenantSubdomain } is passed on connect —
a JWT for the authenticated control panel, the same ?t= subdomain for the public screen (see
game-session.gateway.ts’s handleConnection) — and re-sent automatically on every reconnect, since socket.io-client
re-fires connect (and this hook re-joins) on its own after a dropped connection recovers.
A real lobby, not just “Get ready…”. Previously GameScreenDisplay rendered a static placeholder whenever
currentQuestion was null; now that this state is actually reachable (session created, no question live yet — see
the API-side lobby fix above) it renders a real Lobby: the join code in large type, a QR code (qrcode, already a
dependency from Forms sharing — no new one added) pointing at the member app’s join URL for the resolved tenant, and
a live “N joined” count. The control panel’s own “Question X of Y” line is suppressed during this state (it
previously showed “Question 1 of N” while simultaneously saying “Waiting to start the first question…” directly
below it — a real, if cosmetic, contradiction) and its Next-Question button relabels to “Begin Game” for this one
first press, since it’s a semantically different action from advancing past an already-revealed question even
though it’s the same endpoint call.
Live-joining names on the projector (JoinedNames, game-screen-display.tsx) — a “1 joined” count alone gives
a room no sense of who’s actually there or that the screen is live at all. Every participant is tied at 0 points
during the lobby, so GameSessionStatePayload.leaderboard — already computed unconditionally by getSessionState,
no backend change needed — is, at that point, simply “who’s joined so far,” ordered by join time (the same
createdAt tie-break getLeaderboard already applies elsewhere). Rendered as a wrapping row of name pills using
the same motion (LazyMotion + domAnimation + m) already adopted for leaderboard reordering, plus
AnimatePresence for the enter/exit transition — each pill keeps its participantId as its key across every
poll/broadcast, so a new arrival pops in on its own instead of the whole list re-animating on every update. Capped
at 24 visible names (MAX_VISIBLE_JOINERS) — showing the most recent joiners, not the earliest, so a brand new
arrival is always visible even once a room is past the cap — with a plain “+N more” beyond that, so a genuinely
large room doesn’t turn into visual noise.
Made more prominent, to actually encourage joining, not just confirm it: three follow-up additions, all on the
same lobby. (1) JoinedCount replaced the small static “N joined” line with a large tabular-nums number that
animates smoothly from its previous value to the new one on every change (useAnimatedCount) — a number visibly
climbing reads as a room filling up live; a static label doesn’t. (2) useLatestJoinerHighlight tracks whichever
participant most recently joined and returns their id for 4 seconds (RECENT_JOIN_HIGHLIGHT_MS) — deliberately
does not highlight anyone already present on the screen’s first render (it could load after several people have
already joined; only genuinely new arrivals after that count), comparing each update’s participant-id set against
the previous one it already recorded. (3) That highlighted pill gets a distinct amber ring/fill and briefly scales
up (1.12×, settling back to 1× via the same spring once the highlight expires), paired with a PartyPopper
“{name} just joined!” callout above the pill row — a real Lucide icon, not an emoji character, matching this
codebase’s convention elsewhere.
Found and fixed alongside this: the socket handleJoin bug that left a genuinely idle lobby (exactly this screen,
on a real fresh session) stuck on “Reconnecting…” forever — see the WebSocket section’s own note on it above.
Past sessions, surfaced (app/games/[id]/page.tsx): a new “Past sessions” table below the question builder,
backed by GET admin/games/:id/sessions — each row (code, status, start time, participant count, top scorer) links
to app/games/present/[sessionCode], which now renders read-only for an ENDED session regardless of the viewer’s
write permission (see the games:read relax above). Closes the actual gap behind “no record of who won” — the
per-session data already existed, there was just no way to find a past session’s code again once you’d navigated
away from wherever it was started.
GameSessionStatePayload.gameTitle (session.game.title) is included so the control panel and the presentation
screen can both display which game is running instead of just the join code — most visibly on the end-of-session
card, which previously only said “Session Ended” with no indication of which game just finished. Both now lead with
the game’s title and a warmer “That’s a wrap” framing, plus (on the control panel) the leaderboard’s #1 entry inline
(“{name} takes the win with {score} pts!”).
Member-facing player (discuva-member mobile, components/layout/game-session.tsx, hooks/use-game-session.ts): this
surface fetches the same public GameSessionStatePayload but originally polled it on its own schedule (LIVE_POLL_MS
= 2s while a question is active, no socket) rather than sharing the admin/screen views’ socket-driven state — it had
fallen out of sync with the countdown fix above (rendering the raw secondsRemaining snapshot, only visibly updating
once per poll) and was missing the gameTitle/currentQuestionStartedAt fields entirely. Brought in line:
calcGameSecondsRemaining() (mirroring the same-named helper in use-games.ts) ticks a local nowMs every second
off currentQuestionStartedAt, and the game title now renders above the question.
Now on the same socket as discuva-admin, not polling alone. This app previously had no real-time client
precedent at all, so the 2s/5s adaptive poll above was a deliberate choice at the time. Once the gateway’s
tenant-context bug was fixed (see the Games Module WebSocket section above) and discuva-admin’s socket hook actually
started working, that reasoning no longer held — hooks/use-game-session-socket.ts is ported over (new
socket.io-client dependency, none existed here before), and useGameSession now applies session:state pushes
directly via the socket, falling back to the same 30s safety-net poll discuva-admin’s control panel/presentation
screen already use (enabled: !connected) rather than the original always-on fast poll — connecting with the
member’s own JWT (tokenStore.get()?.accessToken), which already carries schemaName, so no subdomain path is
needed here the way the public admin projector screen requires.
Bug fix: a failed answer submission locked the member into “answered” forever, with no explanation.
QuestionCard.handleAnswer set its local “selected” state the instant a button was tapped, before the request even
resolved — so a rejected submission (already answered, the question advanced underneath it, or now, past the new
server-side time limit) left the UI permanently stuck on “Answer submitted — waiting for the host…”, and
useSubmitAnswer’s own error state, though correctly populated, was never even read by the component. Fixed by
splitting a pendingIndex (set optimistically, cleared on failure) from a submittedIndex (set only once the
server actually confirms it) — only the latter drives the “answered” lock, and error is now rendered so a failure
is visible and, for a retriable cause, doesn’t leave the buttons dead. Option buttons also now disable once
secondsRemaining hits 0, pre-empting the new server-side time-limit rejection rather than trading one confusing
failure for another. Regression-tested in components/layout/__tests__/game-session.test.tsx.
A real lobby here too. The “Waiting for the host to start the first question…” state was already correctly
rendered before this session’s fixes — it just wasn’t reachable, since startSession used to begin Question 1
immediately. Now that it is, it got real treatment instead of staying a placeholder: a pulsing icon, “You’re in!”,
and a live participant count.
Standings are never shown to a member while the game is still LIVE — only once it ends. A compact live
leaderboard used to render under every question and in the lobby itself, updating as scores came in — which gave
away the whole competitive outcome well before the game actually finished, with no suspense to the reveal at all.
Removed entirely from every LIVE state (lobby included); Game Over!'s full LeaderboardList remains the only
place a member ever sees where they landed. LeaderboardList’s now-unused compact prop (only ever passed by the
removed call site) was removed along with it, rather than left as dead flexibility.
Leaving mid-game now needs confirming. Checked directly and confirmed there was no guard at all — the header’s
back button navigated away instantly, and neither the device/browser back gesture nor closing the tab were
intercepted in any way. Guarded whenever status === 'LIVE' (lobby or mid-question — ENDED has nothing left worth
guarding), via three mechanisms: the in-app back button, a beforeunload listener prompting the browser’s own
native dialog on a tab close/refresh, and a popstate listener for the device/browser back gesture specifically —
a sentinel history entry (window.history.pushState({gameGuard: true}, "")) is pushed the moment the game becomes
active and re-pushed immediately on every popstate (synchronously canceling the actual navigation, independent
of what’s decided next — the browser would already be gone before there was anything left to ask, otherwise).
Not window.confirm(), on reflection — a real in-app modal instead, matching this codebase’s own precedent
(plan-gate-modal.tsx, this app’s one other confirmation dialog) rather than the first pass at this fix, which did
reach for window.confirm(). A native confirm dialog can’t be styled or branded, only offers generic OK/Cancel
button labels instead of something like “Leave Game”/“Stay”, and looks especially out of place in an installed PWA
with no browser chrome around it to visually anchor it. The back button and the popstate handler both now just
call setShowLeaveConfirm(true) instead of blocking synchronously on window.confirm() — the modal’s own
“Stay”/“Leave Game” buttons drive the actual decision asynchronously.
Extracted into a shared ConfirmModal (components/ui/confirm-modal.tsx), reusing the same
backdrop/card pattern PlanGateModal established (fixed inset-0 z-[60] backdrop + bg-white rounded-2xl card),
rather than leaving the pattern inlined once per call site (it started as a local LeaveGameConfirm function in
game-session.tsx, now removed in favor of the shared component). Takes title/message/confirmLabel/
cancelLabel/onConfirm/onCancel, plus a variant: "destructive" renders a red confirm button for an action
that actually changes or ends something, "default" a plain dark one. Applied to every remaining
window.confirm() call site in this app — there were two: game-session.tsx’s leave-game guard (above), and
front-desk-session.tsx’s “End this service?” (handleEnd used to block synchronously on
window.confirm("End this service? This can't be undone."); now showEndConfirm state opens the modal, and the
actual end(sessionCode) call moved into a handleConfirmEnd fired only from the modal’s “End Service” button).
Game history (GET games/my-history, app/games/history/page.tsx, components/layout/games-history.tsx,
hooks/use-game-session.ts’s useMyGameHistory): the first member-facing surface for “did I win,” following the
same page/component split app/service-history/page.tsx → components/layout/service-history.tsx already
establishes — a card per past session (title, date, score, rank, correct/answered count), linked from the join
screen (components/layout/games-join.tsx).
Engagement polish, both frontends: evaluated against the five effects wanted (question transitions, timer
urgency, correct/incorrect reveal, score count-up, a game-over celebration) plus live leaderboard reordering, and
landed on motion for exactly one of them — reordering — with everything else staying plain CSS:
- Leaderboard reordering (
motionv12, new dependency in both repos): rows sliding to their new rank as scores change is a real FLIP animation, genuinely painful to hand-roll from raw DOM rects, and the one effect in this pass that earns a library. Imported viaLazyMotion+domAnimation+ the lightweightmcomponent (not the fullmotioncomponent) to keep the bundle cost down (~15 KB gz vs ~35 KB) — nothing here needs gestures or drag.discuva-member’sLeaderboardList(components/layout/game-session.tsx) wraps each row in<m.div layout>, keyed byparticipantId.discuva-admin’s present-page leaderboard was a<table>/<tr>structure —layoutanimation doesn’t play well with the browser’s own table layout algorithm, so it was converted to a plain flex-row<div>list first (same visual spacing, now genuinely animatable).motionrespectsprefers-reduced-motionautomatically; nothing extra was needed there. - Question transitions (CSS only, free):
QuestionCardalready remounts per question (key={question.id}at the call site), so ananimate-fade-in-upclass (reusing a keyframe that already existed inapp/globals.cssbut was unused) is the entire enter animation — no library, no manual reset logic. - Timer urgency (CSS only): a new
animate-timer-urgentkeyframe (a scale pulse, distinct from the existing red color-change threshold) applied at ≤5s remaining, in both the member countdown and the projector screen’s giant one (game-screen-display.tsx). - Correct/incorrect reveal (CSS only):
animate-answer-correct(a small pop) /animate-answer-incorrect(a shake) on the selected option button the instant a result arrives, member-side only — the admin/projector views never show which option a specific member picked. - Score count-up (CSS-adjacent, no library): a ~25-line
useCountUphook (game-session.tsx) animates 0 → the awarded points over ~500ms viarequestAnimationFrameinstead of the number just appearing, resetting via a deferred rAF callback (not a synchronoussetStatein the effect body — this codebase’s ownreact-hooks/set-state-in-effectlint rule catches that pattern) whenever a fresh question arrives. - Game-over celebration (
canvas-confetti, new dependency in both repos, ~2.5 KB gz, no React coupling): fires once (auseRefguard, since the ENDED state can re-render repeatedly from the safety poll/socket without actually re-firing) on the member’s “Game Over!” screen and the projector’sFinalResults— the latter matters most, since it’s the one screen a whole room is actually watching together. Both skip it entirely underprefers-reduced-motion, checked viawindow.matchMedia. prefers-reduced-motionreset: discuva-admin’sapp/globals.csshad no such block at all before this — added the same wildcardanimation-duration/transition-durationoverride discuva-member’s already had.
Admin question-builder (app/games/[id]/page.tsx, QuestionForm.removeOption): deleting the option currently
marked correct used to silently reassign “correct” to whichever option shifted into that slot instead of clearing
the selection — e.g. options [A,B,C,D] with C marked correct, deleting C, silently left B marked correct
with no admin action. Now deleting the correct option resets correctOptionIndex to an unset sentinel (-1) and
canSubmit requires a non-negative index, so the form blocks saving until the admin explicitly re-picks the correct
answer.
“Status stays on Draft” investigation: confirmed via direct testing that startSession/endSession flip
Game.status correctly and immediately server-side (this Next.js version’s client Router Cache also defaults
staleTimes.dynamic to 0, so it wasn’t a caching issue either). The frontend list/detail pages
(app/games/page.tsx, app/games/[id]/page.tsx) still call router.refresh() after starting/ending a session, and
listen for pageshow/visibilitychange to refetch if the page was restored from the browser’s back-forward cache
(bfcache) — real, if secondary, sources of staleness for a plain client-fetched list. But the actual bug reproduced
in this instance was the Game.status drift described above: a session left LIVE from before the duplicate-session
guard existed meant a later session’s endSession call reset Game.status to DRAFT while the orphaned earlier
session was still LIVE underneath — so the list correctly showed DRAFT (matching Game.status), while
startSession correctly 400’d on any attempt to start a new one, with no UI path back to the still-live orphan since
activeSessionCode was, at the time, also gated on Game.status === LIVE_SESSION_ACTIVE. Sourcing
activeSessionCode directly from GameSession (above) closes that gap.
Service Rating Module
Member-to-church pulse after a service — a 1–5 star rating plus optional comment, keyed off (event, serviceSlot, member) (the same pair Attendance keys off, not ServiceSession — the live-control-room entity, which isn’t
guaranteed to exist for every service). Structurally distinct from the Pastor Feedback module: Pastor Feedback is
worker/HOD → leadership, weekly, per-department narrative reporting; this is member → church, per-service,
numeric+comment. POST service-ratings upserts — one rating per member per service occurrence, submitting again
edits the existing row in place.
Anonymity design: the admin comment feed (GET admin/service-ratings/comments) never joins/exposes member
identity unless the requesting admin’s role includes SERVICE_RATING_MODERATE — service_rating:read alone gets an
aggregate view and an anonymized comment feed (member: null per row); service_rating:moderate additionally
reveals member: { id, firstname, lastname } on the same response and is required to delete/hide a comment
(DELETE admin/service-ratings/:id). This is deliberate: default admin access should give a pulse on sentiment
without turning ratings into a place to publicly identify who said what about a specific worker/sermon.
Index: getComments() filters WHERE comment IS NOT NULL ORDER BY created_at DESC across all events (a global
moderation feed, not scoped to one event/slot) — served by a partial index,
IDX_service_ratings_created_at_with_comment ON service_ratings (created_at DESC) WHERE comment IS NOT NULL, which
stays small since most ratings have no comment.
getSummary() aggregates in SQL: GROUP BY rating (at most 5 rows back), not a getMany() that pulls every
matching row into memory to sum/count in application code — this endpoint is unbounded by design (any date range,
any event), so aggregating in Postgres keeps it flat as ratings accumulate instead of degrading linearly.
Mobile comment capture (discuva-member, components/layout/attendance.tsx): the star-tap itself still submits
instantly with no comment (unchanged, one-tap). Once rated, an “Add a note” affordance appears — expands a small
textarea, and sending it re-calls submitRating with the same rating plus the comment (an upsert, so no separate
endpoint). Previously nothing in the UI ever sent a comment, so the admin moderation feed above was unreachable in
practice regardless of backend support.
Routes (member, JwtAuthGuard + @RequiresModule('service_ratings')): POST service-ratings (upsert),
GET service-ratings/mine?eventId=&serviceSlotId= — always returned comment on the full entity; the mobile widget
above now actually reads it, to restore an existing note on revisit.
Routes (admin, AdminGuard): GET admin/service-ratings/summary?eventId=&from=&to= (SERVICE_RATING_READ,
average + 1–5 distribution), GET admin/service-ratings/comments?page=&limit= (SERVICE_RATING_READ),
DELETE admin/service-ratings/:id (SERVICE_RATING_MODERATE, logs SERVICE_RATING_MODERATED).
Not audited: individual rating submissions — matches the existing judgment call on high-frequency member actions (same as Games answers and announcement reactions). Only moderation (deletion) is audited.
Routes prefix: /service-ratings, /admin/service-ratings
Volunteer Module
A self-service serving marketplace — admins post VolunteerOpportunity records (title, optional description,
department for admin-side categorization/reporting only — not access control, same convention as Game),
members browse and sign themselves up. Genuinely new interaction pattern for this codebase: every other
member-facing “assignment” (department membership, prayer roster, service-programme slots) is admin-assigned:
this is the first admin-posts-a-slot / member-claims-it flow.
Capacity enforcement: VolunteerOpportunity.confirmedCount is a denormalized counter (mirrors
PrayerMeeting.currentCapacity’s precedent), maintained inside a DB transaction that takes a pessimistic_write
lock on the opportunity row before checking capacity and incrementing/decrementing — this is the same
lock-then-check-then-mutate shape PrayerMeetingService.selectMeeting already uses, preventing two concurrent
sign-ups from both slipping past a capacity check that a non-transactional COUNT query could race. capacity: null
means unlimited.
Sign-up is an upsert, not insert-only: VolunteerSignup is unique on (opportunity, member). Cancelling
(status → CANCELLED) and re-signing-up flips the same row back to CONFIRMED rather than erroring on a duplicate
key or leaving orphaned rows — same “one row per (parent, member), status toggles” idiom as AnnouncementReaction
and ServiceRating, just with a two-state status instead of upsert-the-value.
No hard delete on opportunities: the admin “remove” action is PATCH .../cancel (→ status = CANCELLED,
audit-logged), not DELETE — mirrors this codebase’s general preference for deactivation over deletion
(see Member Deletion Policy) once a record may have dependent children (signups here).
Member list includes own status inline (GET volunteer-opportunities): each row carries
mySignupStatus: 'CONFIRMED' | 'CANCELLED' | null for the requesting member, computed via one extra batched query
against the returned page’s opportunity IDs — avoids a separate “my signups” round-trip just to render a
Sign-Up-vs-Cancel button per row. Only status = OPEN and date >= now opportunities are listed (past/closed/
cancelled opportunities don’t show in the browse list, though they remain visible to admins) — this is the
member-facing “browse open opportunities” feed, hit on every load, served by a composite
IDX_volunteer_opportunities_status_date ON volunteer_opportunities (status, date) index (in addition to the
original date-only index from the marketplace’s initial migration).
Mobile pagination (discuva-member, hooks/use-volunteer.ts): the backend route was already properly paginated
(page/limit); the mobile hook previously just hardcoded limit=50 and never advanced past page 1, silently
truncating the browse list once a church had more than 50 concurrently-open opportunities. Now tracks page/
totalPages and exposes goToPage, rendered as the same prev/next pager components/layout/sermons.tsx already
established for its own list — the standard pattern for a paginated list on this mobile app.
Routes (admin, AdminGuard + ModuleEnabledGuard): POST/GET/PATCH admin/volunteer-opportunities (/:id for
single-record routes), PATCH admin/volunteer-opportunities/:id/cancel,
GET admin/volunteer-opportunities/:id/signups (roster, CONFIRMED only) — all VOLUNTEER_READ/VOLUNTEER_WRITE.
VolunteerAdminController is also @RequiresModule('volunteering') (previously missing — every other admin
controller in this codebase pairs AdminGuard with ModuleEnabledGuard; without it, admins could manage
opportunities even with the volunteering module disabled in church settings, inconsistent with the member-facing
controller which already had the check).
Routes (member, JwtAuthGuard + @RequiresModule('volunteering')): GET volunteer-opportunities,
POST volunteer-opportunities/:id/signup, DELETE volunteer-opportunities/:id/signup — any authenticated
member/worker, no department/class gating (open to anyone, same “no access-control implication” stance as Game
and Sermon’s department/categorization fields).
Not audited: routine cancel-my-own-signup — matches the established judgment on high-frequency, low-stakes
member actions. VOLUNTEER_SIGNUP_CREATED (a commitment, worth a record unlike a passive reaction) and admin
actions (VOLUNTEER_OPPORTUNITY_CREATED/_UPDATED/_CANCELLED) are audited.
Routes prefix: /volunteer-opportunities, /admin/volunteer-opportunities
Member Directory Module (src/member-directory/)
Opt-in professional/business discoverability — members search each other by name, occupation, business, or skills (“who in the church is an accountant,” “does anyone run a catering business”) to drive collaboration. Deliberately scoped narrower than the original idea it came from: member-to-member chat and member-created interest groups were both explicitly deferred (chat as a genuine trust/safety decision to make deliberately later, not a technical default; interest groups deprioritized in favor of shipping the directory itself first).
Entity MemberDirectoryProfile (member_directory_profiles) — a separate entity from Member rather than
columns bolted onto it, so the whole feature stays cleanly removable via one migration if it’s ever pulled: 1:1 with
Member (member_id unique FK, ON DELETE CASCADE), occupation, businessName (kept separate — “I’m an
accountant” and “I run Adaeze’s Catering” are independent facts a member may want to share one, both, or neither
of), skills (free text, comma-separated — deliberately not a Postgres array column, so it stays searchable with
the same LOWER(...) LIKE convention as every other field here, no new query technique), bio (text).
Visibility is opt-in, no moderation step — isVisible (default false) mirrors Testimony.isPublic’s
“submitter’s own flag, no separate publish/approval step” precedent. showPhone/showEmail (both default false)
are deliberately separate from isVisible: surfacing contact info is a materially bigger privacy step than showing
an opted-in occupation/business/bio, so a member can be discoverable without exposing how to reach them directly.
Search (MemberDirectoryService.search) reuses this codebase’s existing search convention
(MemberService.getAll’s LOWER(field) LIKE LOWER(:s) pattern) across firstname/lastname/occupation/
businessName/skills, scoped to isVisible = true only. The response mapping omits phoneNumber/email per row
unless that row’s own showPhone/showEmail is true — the same “deliberately trim sensitive fields out of the
response” precedent MemberService.searchActiveMembersLite() already established for the admin check-in picker.
Paginated (Pagination Policy: member lists grow unboundedly).
Discoverability nudge: GET member-directory/me/completion returns whether a member’s own listing is visible
and has at least one of occupation/business/skills set (isDiscoverable) — the frontend uses this to show a prompt
encouraging the member to fill in their profile and opt in, directly serving the “get members to add their
professional/business details” goal rather than leaving the feature to sit empty by default.
Admin analytics (GET admin/member-directory/analytics, MEMBER_DIRECTORY_READ, read-only by design — admin
never edits an individual member’s listing, only views aggregates): total opted-in count and a profession
breakdown grouped by occupation, each with the list of members holding it, sorted by count descending. Never
returns phone/email regardless of a member’s own showPhone/showEmail choice — this is a church-wide statistics
view, not a directory lookup; an admin who needs to contact a member already has that via the regular member
record.
Gated on three independent axes:
KNOWN_MODULESkeymember_directory(required: false) — tenant admin’s own on/off toggle.PlanFeature.MEMBER_DIRECTORY— Pro plan only (migrationAddMemberDirectoryToProPlanappends it to the already-seededprorow’sfeaturesarray, same idiom asAddFormsToProPlan).KNOWN_ASSETSkeymember-directory-hero— lets a tenant admin upload a custom header image for the directory screen via the existing Appearance page (GET tenant/assets/catalogis rendered generically there, so no discuva-admin change was needed for this to appear).
Routes prefix: /member-directory (member/worker, JwtAuthGuard + ModuleEnabledGuard + PlanGuard),
/admin/member-directory (admin, AdminGuard + same module/plan guards).
Small Group Module (displayed to users as “Fellowships”)
Cell/home-fellowship tracking — the structural gap identified as the biggest single engagement-platform gap for
the target congregations (most run more on cell structure than department structure). Three entities: SmallGroup
(name unique, description, leader — a Member, deliberately not restricted to WorkerProfile/Admin since
cell leaders in this context aren’t necessarily on the worker roster — meetingDay/meetingLocation as free-text,
not an enum), SmallGroupMember (join table, unique on (group, member)), SmallGroupAttendance (unique on
(group, member, meetingDate) — re-recording the same date edits in place, same upsert idiom as
ServiceHeadcount).
Venue + online meeting support: SmallGroup also carries venue (Venue | null, ManyToOne, SET NULL on
delete — informational only, unlike EventConfig.defaultVenue’s RESTRICT, since losing the link on venue
deletion doesn’t break any live check-in flow), meetingFormat (MeetingFormatEnum, shared with EventConfig,
default IN_PERSON), and meetingLink (string | null). venue is added alongside, not instead of, the
existing free-text meetingLocation — most fellowships meet informally (e.g. a member’s home) with no registered
Venue row, so venue only covers the minority case of a fellowship meeting at an actual church-registered venue.
No cross-field validation is enforced server-side (unlike EventConfig’s IN_PERSON/ONLINE venue requirement) — a
fellowship is a much softer entity than a live check-in service, so an admin can freely leave both venue and
meetingLocation unset, or set either/both regardless of meetingFormat.
Membership is self-service, no approval step: POST small-groups/:id/join upserts-by-returning-existing
(mirrors VolunteerService.signUp’s “already confirmed → return the existing row” shape) rather than erroring on
a duplicate join — including under a concurrent double-tap: the initial existence check leaves a race window before
the insert, so join() also catches the (group, member) unique-constraint violation (Postgres 23505) and
re-fetches/returns the now-existing row instead of letting a raw DB conflict surface as a 500. DELETE small-groups/:id/leave is self-leave; admin-forced removal (DELETE admin/small-groups/:id/members/:memberId) is a
separate action, audited SMALL_GROUP_MEMBER_REMOVED (routine self-join/leave is not audited — matches the
established judgment on high-frequency member actions).
A group’s leader is not auto-enrolled as a member: create/update only set SmallGroup.leader, they never
insert a SmallGroupMember row for that person. getMembers()'s access check (assertIsGroupMember) therefore also
accepts the caller being group.leader.id, not just an existing membership row — otherwise a leader who never
separately “joined” their own group would be locked out of viewing its own roster (the mobile “Take Attendance” flow
calls this route first). assertIsGroupLeader() (used by recordAttendance) already worked this way; this just
brings roster access in line with it.
Index: listMine() (a member’s “My Fellowships” tab) filters small_group_members by member_id alone. The
table’s only prior index was IDX_small_group_members_group_id plus the (group_id, member_id) unique constraint —
both lead with group_id, so neither serves a member-only lookup. Added
IDX_small_group_members_member_id ON small_group_members (member_id).
Admin getRoster/getAttendanceHistory are now paginated: both previously returned every row for a group
unbounded — getAttendanceHistory in particular grows forever (one row per member per meeting, for the life of the
group), against the documented pagination policy. Both now take page/limit and return the standard
PaginationResponseDto shape ({ data, page, limit, totalCount, totalPages }) instead of a bare array — a breaking
response-shape change for GET admin/small-groups/:id/members and GET admin/small-groups/:id/attendance, updated
on the only consumer (discuva-admin/app/small-groups/page.tsx, hooks/use-small-groups.ts) to unwrap
res.data.data.data and render the shared PaginationBar per tab, same pattern the groups list itself already
used. Backed by two new composite indexes (ORDER BY meeting_date DESC/created_at ASC within a group, previously
only covered by the single-column group_id index):
IDX_small_group_attendance_group_id_meeting_date ON small_group_attendance (group_id, meeting_date DESC) and
IDX_small_group_members_group_id_created_at ON small_group_members (group_id, created_at ASC).
Leader-gated attendance-recording, not admin-gated: POST small-groups/:id/attendance sits under
JwtAuthGuard, not AdminGuard — a group leader need not be a worker or admin, so SmallGroupService’s private
assertIsGroupLeader() (mirrors assertHasCapability’s shape: throws ForbiddenException if
group.leader?.id !== callerId) is the only gate, independent of the admin permission system entirely. Body:
{ meetingDate, records: [{ memberId, status }] } — each record upserts against the unique
(group, member, meetingDate) constraint.
Full member roster requires group membership (GET small-groups/:id/members): gated by
assertIsGroupMember() (any current member, not leader-only) — lets a leader see who to mark attendance for and
lets ordinary members see their own group’s “family,” while still keeping the full roster invisible to a member
who’s browsing groups they haven’t joined yet. The browse list (GET small-groups) only exposes a memberCount,
not the roster itself.
No archive/cancel state (unlike VolunteerOpportunity): DELETE admin/small-groups/:id is a real delete —
groups are simpler organizational units without the same “keep history after the window closes” need a volunteer
opportunity has, so this follows Game’s full-CRUD precedent rather than VolunteerOpportunity’s
cancel-don’t-delete one.
Routes (admin, AdminGuard): POST/GET/PATCH/DELETE admin/small-groups (/:id for single-record routes),
GET admin/small-groups/:id/members?page=&limit=, DELETE admin/small-groups/:id/members/:memberId,
GET admin/small-groups/:id/attendance?page=&limit= — all SMALL_GROUP_READ/SMALL_GROUP_WRITE.
Routes (member, JwtAuthGuard + @RequiresModule('small_groups')): GET small-groups, GET small-groups/mine,
GET small-groups/:id, GET small-groups/:id/members (current members only), POST small-groups/:id/join,
DELETE small-groups/:id/leave, POST small-groups/:id/attendance (leader only, enforced in-service not by guard).
Routes prefix: /small-groups, /admin/small-groups
Platform Admin (Control Plane)
The SaaS control plane for the multi-tenant/freemium platform — see docs/MULTI_TENANT_MIGRATION.md for the full
design. Entirely separate from everything else in this document: it operates on public schema tables
(tenants, platform_admins, plans, subscriptions, communication_providers,
tenant_communication_provider_configs, giving_providers, tenant_giving_provider_configs,
payment_providers) that describe tenants themselves, never a tenant’s own
business data, and authenticates against a completely disjoint identity system (PlatformAdmin, not Member/Admin).
Auth: PlatformAdminGuard (validates the platform-admin-jwt Passport strategy, signed with
PLATFORM_ADMIN_JWT_SECRET — a different secret from JWT_SECRET, so a tenant token can never pass as a platform
one or vice versa). The whole controller is @Public() at the class level — this is load-bearing, not
decorative: JwtAuthGuard is a global APP_GUARD that runs on every route regardless of any @UseGuards() also
applied, and a platform admin never has a tenant JWT to satisfy it. @Public() skips only that global guard;
PlatformAdminGuard still independently protects every route except login.
Refresh session (POST /platform/auth/refresh): access tokens are short-lived (PLATFORM_ADMIN_JWT_EXPIRY_IN,
default 1h) and the frontend only ever keeps one in memory, never localStorage — so until this existed, a page
reload (or the access token simply expiring mid-session) logged every platform admin out unconditionally, with no
recovery besides a fresh password login. POST /platform/auth/login now also signs a refresh token
(PLATFORM_ADMIN_REFRESH_JWT_SECRET/_EXPIRY_IN, default 7d — deliberately its own secret, not shared with
either PLATFORM_ADMIN_JWT_SECRET or the tenant-side REFRESH_JWT_SECRET) and sets it as an httpOnly
platform_refresh_token cookie, scoped to path /v1/platform/auth and never returned in the JSON body.
PlatformAdminRefreshJwtStrategy reads that cookie name specifically — deliberately distinct from the tenant
member/admin refresh_token cookie, since both are set by the same shared api.discuva.org host across every
frontend origin, and reusing the same cookie name would let one clobber the other for any browser logged into both
discuva-admin and discuva-platform. refreshAccessToken() is stateless (no session/rotation-tracking table,
matching the access-token strategy’s own validateById re-check) — it just re-confirms the admin is still active
and issues a fresh token pair; the browser keeps sending the same refresh cookie until its own 7-day expiry.
POST /platform/auth/logout clears the cookie (previously logout was purely client-side, never told the backend at
all — the cookie would have just kept silently re-authenticating an ostensibly “logged out” session otherwise).
Permissions (PlatformAdminPermission, src/platform-admin/enum/). Every platform admin used to be binary —
isActive: true meant full access to every /platform/* route, false meant none. PlatformAdminRole (mirrors
tenant-side AdminRole exactly: name, description, permissions: string[]) now sits between them, and
PlatformAdminGuard does double duty as both the JWT-validating guard and the permission-checking guard (unlike
tenant-side, where a global JwtAuthGuard + a separate per-route AdminGuard split that job — /platform/* has no
global-guard equivalent to lean on, since every platform controller applies PlatformAdminGuard explicitly). A
platform admin’s permissions are loaded once, at JWT-validation time (PlatformAdminAuthService.validateById
eager-loads the platformAdminRole relation), not a second DB round-trip per request. @RequiresPlatformPermission(...)
mirrors tenant-side @RequiresPermission(...) and is applied per-route (or once at class level when every route in
a controller needs the same permission, e.g. PlatformAnalyticsController). Thirteen permissions across seven
groups — see PlatformAdminPermissionGroups for the exact list, used to render a grouped permission picker.
BROADCAST_WRITE (added alongside the tenant-broadcast capability below) needed a data migration, not just an
enum addition, to actually reach an already-seeded SuperAdmin role — PlatformAdminRole.permissions is a plain
text[] snapshotted once at row-creation time (DefaultPlatformAdminSeed never re-syncs an existing role against
the enum on later boots), so adding a new permission value does nothing for a platform admin whose role already
existed. See 1792371600000-GrantSuperAdminBroadcastPermission.ts.
Tenant health stats (GET /platform/tenants) include live memberCount/eventCount per tenant via
schema-qualified reads — cheap at the tens-to-low-hundreds tenant scale this product targets today, not a design
that scales to thousands of tenants without revisiting. impersonate issues a short-lived, access-token-only
JWT (no refresh token, no session record) signed directly rather than through the normal admin-login path — see
the code comment on PlatformTenantService.impersonateTenant for why. TenantMiddleware is wired into the live
request pipeline (§5 Multi-Tenant Request Scoping below), so that token routes to the correct tenant schema like
any other tenant-facing request.
Every method that hands a tenant back to a platform-admin caller (listTenants, createTenant, updateTenant,
suspendTenant) goes through one private toHealthShape() builder — previously createTenant/updateTenant/
suspendTenant returned the raw Tenant entity via tenantRepo.save(), leaking internal columns
(schemaName, clusterId, parentTenantId, shareDataWithParent/shareGivingWithParent) that have no business
being visible outside this service. All four routes now return the identical curated shape.
| Method | Route | Description |
|---|---|---|
| POST | /platform/auth/login |
Platform admin login — { email, password }, returns { accessToken, requiresPasswordChange } and sets the httpOnly platform_refresh_token cookie. |
| POST | /platform/auth/refresh |
PlatformAdminRefreshJwtAuthGuard (validates the refresh cookie, a separate check from PlatformAdminGuard). Returns a fresh { accessToken } and re-sets the refresh cookie. |
| POST | /platform/auth/logout |
Clears the refresh cookie. 204. |
| GET | /platform/tenants |
List all tenants — profile fields (logoUrl/tagline/address/supportEmail/currency/timezone), onboardingStatus, plan/subscription status, and live member/event counts. |
| POST | /platform/tenants |
Provisions a new tenant inline (TenantProvisioningService.provision(), not the queue self-serve /signup uses — see “Async Tenant Provisioning + Onboarding State Machine” above). Body has no password field, same as /signup’s SignupDto — the new admin gets a welcome email with a set-password link instead (see “Tenant Welcome / Set Password Flow” above). Returns the tenant already onboardingStatus: ACTIVE, same shape as GET /platform/tenants’ rows. |
| GET | /platform/tenants/:id/onboarding-events |
The platform-level onboarding audit trail for one tenant, oldest first — see “Async Tenant Provisioning + Onboarding State Machine” above. |
| PATCH | /platform/tenants/:id |
Update name, logo, tagline, address, support email, currency, timezone. Returns the same shape as GET /platform/tenants’ rows. |
| PATCH | /platform/tenants/:id/suspend |
{ suspend?: boolean }, default true — same route handles reactivation via { suspend: false }. Returns the same shape as GET /platform/tenants’ rows. |
| PATCH | /platform/tenants/:id/plan |
Manually change a tenant’s plan — comps, support fixes. Sets Subscription.status to active regardless of its prior value, so a canceled/past_due tenant regains access immediately rather than waiting on the next billing-provider webhook. Invalidates PlanGuard’s cached feature list. |
| PATCH | /platform/tenants/:id/discount |
Apply an internal comp — { discountType: 'percentage' | 'fixed_amount', discountValue, discountReason?, discountExpiresAt? }. Requires an existing subscription. Never touches checkout/a payment provider — see Billing & Checkout above. |
| DELETE | /platform/tenants/:id/discount |
Clear a tenant’s discount. |
| POST | /platform/tenants/:id/impersonate |
Issue a scoped support token for that tenant’s admin. |
| DELETE | /platform/tenants/:id |
TENANTS_DELETE permission (separate from TENANTS_WRITE). Permanently deletes a tenant — 409 unless onboardingStatus is PENDING, AWAITING_APPROVAL, or FAILED (an ACTIVE tenant must be suspended instead, never deleted here — this is also how a held signup gets rejected). Drops the tenant’s Postgres schema (DROP SCHEMA IF EXISTS ... CASCADE, a no-op if none was created) before removing the tenants row, which cascades onboarding events/subscriptions/etc. via existing FKs. |
| PATCH | /platform/tenants/:id/approve |
TENANTS_WRITE. Releases a self-serve signup held AWAITING_APPROVAL — 409 otherwise. Reconstructs the provisioning job from pendingSignupParams and enqueues it (still async, same as any self-serve signup). See “Manual Approval Gate for Self-Serve Signups” above. |
| GET | /platform/plans |
List plan rows (every currency/interval variant of every tier). |
| POST | /platform/plans |
Create a plan row — tierKey and billingInterval required, group it with sibling currency/interval variants. See “Multi-currency, multi-interval tiers” under Billing & Checkout above. |
| PATCH | /platform/plans/:id |
Edit a plan row’s price/currency/billingInterval/features/featureLimits/tierKey. 400 if changing currency or billingInterval on a row that already has a billingProviderPriceId — see “Multi-currency, multi-interval tiers” above. |
| GET | /platform/capabilities |
[{ key, label }] — every valid features/featureLimits key (every KNOWN_MODULES entry plus the 4 module-less PlanFeature values), labeled for the Plans page’s checkbox list. See “Every toggleable module is also a plan-assignable capability” above. |
| GET | /platform/subscriptions |
List all subscriptions — spot past_due churn risk. |
| GET | /platform/communication-providers |
List platform-wide registered SMS/email providers. |
| POST | /platform/communication-providers |
Register a new provider — { id, channel, name }. |
| PATCH | /platform/communication-providers/:id |
{ isActive: boolean } — activate/deactivate a provider in the platform-wide catalog. See “Communication Providers: deactivation has real consequences” below for what this actually does to a tenant already using the provider. |
| GET | /platform/tenants/:id/communication-providers |
A tenant’s active provider per channel — never the raw encrypted credentials. |
| GET | /platform/analytics/overview|/growth|/revenue|/engagement|/churn|/adoption |
Cross-tenant business metrics — see “Platform Analytics” below. |
| GET | /platform/tenants/:id/billing-sessions |
This tenant’s checkout session history, newest first. |
| POST | /platform/billing-sessions/:sessionId/refund |
Refund a completed checkout via the original provider — see Billing & Checkout above. |
| GET | /platform/admin-roles |
List platform admin roles. |
| GET | /platform/admin-roles/:id |
Get one platform admin role. |
| POST | /platform/admin-roles |
Create a role — { name, description?, permissions: PlatformAdminPermission[] }. |
| PATCH | /platform/admin-roles/:id |
Edit a role’s name/description/permissions. |
| DELETE | /platform/admin-roles/:id |
Delete a role — 400 if any active platform admin is still assigned to it. |
| GET | /platform/admins |
List platform admins with their role. |
| GET | /platform/admins/me |
The calling platform admin’s own record + permissions — no permission requirement beyond a valid token. |
| GET | /platform/admins/:id |
Get one platform admin. |
| POST | /platform/admins |
Onboard a new platform admin — { email, platformAdminRoleId }. No password field — the new admin gets a welcome email with a set-password link instead (see below). |
| PATCH | /platform/admins/:id |
Change role and/or isActive. 403s if id is the caller’s own — see below. |
| POST | /platform/auth/forgot-password |
Public, rate-limited (5/min). Request a password-reset OTP for a platform admin. |
| POST | /platform/auth/reset-password |
Public, rate-limited (5/min). Verify the OTP and set a new password — also how a newly-onboarded admin sets their initial one. |
| POST | /platform/broadcast |
{ subject, message } (plain text, not HTML) — one email to every active tenant’s oldest active admin. See “Tenant Broadcasts” below. |
| GET | /platform/settings |
List platform-wide settings (grace period + the five upload-size limits) — see “Platform Settings” below. |
| PATCH | /platform/settings/:key |
{ value: number } — edit a platform-wide setting live, no redeploy. BILLING_WRITE. |
Platform Settings
A generic, platform-wide (not per-tenant) key/value settings store — the platform-admin equivalent of the tenant-side
Church Settings module above, but living in public schema with no tenant dimension: new PlatformSetting entity
(key unique, value: jsonb), read/written through PlatformSettingsService, same short-TTL cache pattern as
ChurchSettingsService/ReminderSettingsService. KNOWN_PLATFORM_SETTINGS
(src/platform-admin/constant/known-platform-settings.constant.ts) is the whitelist — each entry now carries
min/max alongside label/unit/defaultValue, enforced server-side in PlatformSettingsService.upsert()
(400 if out of range) since these vary per key and can’t all share one class-validator bound. GET/PATCH /platform/settings responses include min/max too, so the frontend renders the right bounds per setting instead
of a hardcoded range.
Consumer 1 — subscription grace period: SubscriptionLapseScheduler used to read GRACE_PERIOD_DAYS from
an env var once at boot (a single global value, requiring a redeploy to change). It now calls
PlatformSettingsService.getSubscriptionGracePeriodDays() once per daily run instead — still a single global value
(not per-tenant: this is billing/revenue policy Discuva sets uniformly, not a per-church preference — a deliberate
distinction from the tenant-facing Reminder Settings module above, which covers per-church operational preferences).
The GRACE_PERIOD_DAYS env var and its Joi entry have been removed; any deployed value for it is now inert.
Consumer 2 — upload size limits: MAX_LOGO_UPLOAD_MB, MAX_AVATAR_UPLOAD_MB, MAX_CLASS_MATERIAL_UPLOAD_MB,
MAX_FINANCE_PROOF_UPLOAD_MB, MAX_FORM_ATTACHMENT_UPLOAD_MB, MAX_PAGE_IMAGE_UPLOAD_MB — stored in MB (not bytes, since that’s what a platform admin actually types into
the settings form), read via PlatformSettingsService.getMaxUploadBytes(key) which converts to bytes. These
replace the MAX_LOGO_UPLOAD_BYTES/MAX_AVATAR_UPLOAD_BYTES/MAX_CLASS_MATERIAL_UPLOAD_BYTES/
MAX_FINANCE_PROOF_UPLOAD_BYTES env vars entirely (removed from env.validation.ts) — MAX_FILE_UPLOAD_BYTES
remains an env var, unaffected, since it’s the fallback for routes with no dedicated category (incident report
photos, member bulk-import).
Fixed: tenant-scoped cache namespacing bug. CacheService.get/set/del always namespace by whatever tenant
(if any) is in CLS context — correct for genuinely per-tenant data, but PlatformSettingsService’s values aren’t
tenant-specific. A platform-admin write has no tenant context (scopes to tenant:global:...), but
getMaxUploadBytes() is called from a real tenant-scoped upload request, so it was caching under
tenant:<that-tenant's-id>:... — a platform-admin change never invalidated it, leaving each tenant serving a stale
limit for up to the 300s cache TTL after every change. CacheService now exposes getGlobal/setGlobal/
delGlobal (a genuinely separate global:... key namespace, not the tenant-scoped methods’ coincidental
'global' fallback), and PlatformSettingsService uses them for every cache call, including
getSubscriptionGracePeriodDays() — which happened to dodge this bug only because SubscriptionLapseScheduler
calls it before entering any per-tenant loop, not because it was actually correct.
Enforcing a live limit is a real constraint Multer doesn’t support natively: limits.fileSize has to be a static
number known when the route is decorated, it can’t await a DB/cache read per request. DynamicLimitedFileInterceptor
(src/utility/interceptors/dynamic-limited-file.interceptor.ts) resolves this by letting Multer parse against a
generous, non-configurable hard ceiling (UPLOAD_HARD_CEILING_BYTES, always ≥ the setting’s max) as a safety net,
then checking the actual parsed file’s size against the live platform-configured limit inside intercept()
afterward, rejecting with an accurately-labeled PayloadTooLargeException if it’s over. A file between the live
limit and the hard ceiling is still fully buffered before being rejected — an accepted tradeoff given how small
these ceilings are (tens of MB), rather than reimplementing Multer’s own streaming internals. TenantInfoController
(logo + appearance assets), MemberController (me/photo), ClassesController (materials/upload), and
FinanceWorkerController (requests attachment) all use this interceptor now instead of the static
LimitedFileInterceptor.
PlatformAdminModule is now @Global() so PlatformSettingsService can be injected into
DynamicLimitedFileInterceptor from any consuming module (TenantModule, MemberModule, ClassesModule,
FinanceRequestModule) without each needing an explicit import path — same reasoning UtilityModule documents for
its own @Global() (guards/interceptors resolve dependencies via the consuming controller’s module, not the
declaring module).
Consumer 3 — attendance distance-check default: ENFORCE_DISTANCE_CHECK_DEFAULT — the first boolean
PlatformSetting (every prior one was a plain number). KnownPlatformSetting gained an optional type: 'number' | 'boolean' field purely as a rendering hint (still stored/transmitted as 0/1, no new column or shape) — the
settings page renders a toggle instead of a number input when type === 'boolean'. See “Attendance Distance Check
Setting” in the Attendance Module section above for the full per-tenant-override picture this platform default sits
underneath.
Consumer 4 — social media draft retention: SOCIAL_MEDIA_DRAFT_RETENTION_DAYS (default 30) — read by
SocialMediaRetentionScheduler’s daily sweep (see Social Media Module above). No dedicated frontend work was
needed for this one: /billing-settings already renders every KNOWN_PLATFORM_SETTINGS entry generically from the
GET /platform/settings response, so a new key just appears.
Consumer 5 — self-serve signup approval gate: SELF_SERVE_REQUIRES_APPROVAL (default off) — read by
SignupController.signup() via PlatformSettingsService.getSelfServeRequiresApproval(), awaited since it gates
request flow (this codebase’s Redis convention for a get() that decides what a request does, not merely renders).
See “Manual Approval Gate for Self-Serve Signups” above for the full flow. Another boolean, same rendering-hint
type: 'boolean' /billing-settings toggle as ENFORCE_DISTANCE_CHECK_DEFAULT — no dedicated frontend work needed
here either.
Retired: SOCIAL_MEDIA_ENABLED (formerly Consumer 5 here — a boolean, all-tenants-at-once composer readiness
gate). Removed once Tenant.moduleOverrides shipped (see the Social Media Module and Tenant Module sections
above) — the Social Media Rollout control (single toggle + searchable multi-select, PUT /platform/social-media/rollout) replaces its job with real per-church granularity and actual backend enforcement,
which this setting never had (it only ever gated one frontend check, never the API itself). GET /social-media/platform-enabled still exists and discuva-admin still calls it the same way — see the Social Media
Module section above for what it checks now instead.
Frontend: discuva-platform’s /billing-settings page (own layout.tsx, same “every new route needs one”
convention, now titled “Platform Settings” in-page and in the sidebar since it’s no longer billing-only), gated by
billing:read/billing:write (reusing the existing permission pair /giving-providers and /payment-providers
already use — no new permission introduced for the upload-limit settings, they’re gated the same as every other
platform-wide setting on this page). The per-row number input’s min/max now come from each setting’s own API
response instead of a hardcoded 0–365; a boolean-typed setting renders a toggle switch instead of a number input.
Routes prefix: /platform
Tenant Broadcasts (TenantBroadcastService, added 2026-08)
Sends one email to every active tenant’s oldest active admin — used both as a direct platform-admin action
(POST /platform/broadcast, discuva-platform’s “Broadcast” nav page) and internally by other services that need
to notify every tenant about something platform-wide (first consumer: Communication Provider deactivation, below).
Never a single batched to: [...] call. Confirmed live in EmailProcessor: an array to produces one shared,
mutually-visible To: header (Array.isArray(to) ? to.join(', ') : to) — sending one email to every tenant’s
admin that way would leak every church admin’s email address to every other church admin. TenantBroadcastService
instead uses forEachActiveTenant (already proven by SubscriptionLapseScheduler) to re-enter each tenant’s own
schema and queue one individual EmailQueueService.queueEmail() call per tenant.
Only the tenant’s oldest active admin is notified, same “one primary contact” convention
SubscriptionLapseScheduler already established for platform-initiated notices — not every admin the tenant has.
Two entry points on the service, one plain-text and one raw-HTML:
broadcastPlainTextToAllTenantAdmins(subject, message)— whatPOST /platform/broadcastactually calls. Each non-blank line ofmessagebecomes its own<p>, HTML-escaped first. A platform admin typing into a form textarea should never be able to inject arbitrary markup/scripts into an email reaching every church on the platform at once.broadcastToAllTenantAdmins(subject, html)— the lower-level primitive, for internal callers that need real markup (e.g. a provider-outage notice with a link). Every otherqueueEmailcall site in this codebase passes raw HTML directly; this one is no different, it’s only the plain-text entry point above that restricts it.
Result shape, distinct from forEachActiveTenant’s own { succeeded, failed }: { sent, skipped, failed } —
skipped (a tenant with no active admin on file) is tracked separately from failed (the tenant callback itself
threw), since neither means the same thing operationally.
Permission: BROADCAST_WRITE, deliberately its own permission rather than folded into an existing one — same
“independently grantable, bigger blast radius than it looks” reasoning as TENANTS_IMPERSONATE. See the
migration note earlier in this section for why an already-seeded SuperAdmin role needed a data migration, not
just the enum addition, to actually gain this permission.
Platform Admin Management (/platform/admins, /platform/admin-roles)
Onboarding/permission management for platform admins themselves — previously the only way to create one was a
hand-written SQL insert (there was nothing else to onboard multiple platform admins with, and no way to scope any
of them below full access). PlatformAdminManagementService (users) and PlatformAdminRoleService (roles) mirror
AdminService/AdminRoleService’s tenant-side shape closely, with two differences: no audit-log tie-in (tenant-side
logs into a tenant-scoped audit_logs table this control-plane has no equivalent of, and platform-admin actions
aren’t audited anywhere else in this codebase either), and platform admins have no underlying Member — creating
one is a single step (email + platformAdminRoleId), not tenant-side’s separate “create a member” → “grant them
admin” two-step flow.
POST /platform/admins takes no password — the onboarding admin isn’t the one logging in as the new admin, so
there’s nobody present to choose one. PlatformAdminManagementService.create() generates a random password
internally (never revealed to anyone, changedPassword: false), a 6-digit OTP stored in
platform_admin_password_reset_otps (48-hour expiry — same tradeoff as the tenant-welcome flow, offset by rate-
limiting POST /platform/auth/reset-password), and emails the new admin a platform-admin-welcome template with a
{PLATFORM_LOGIN_URL}/set-password?email=...&otp=... link — the discuva-platform equivalent of the tenant
onboarding flow above, down to reusing the same OTP-verify-and-set-password shape
(PlatformAdminAuthService.forgotPassword/resetPassword, its own OTP table rather than tenant-side’s
password_reset_otps since PlatformAdmin and Member are deliberately disjoint identity systems). Login also now
returns requiresPasswordChange: !admin.changedPassword, mirroring the tenant-side login response shape, though
nothing currently enforces it in the frontend — a random, unrevealed password can’t be logged in with in practice,
so the flag is informational/defense-in-depth, not an enforced gate.
PATCH /platform/admins/:id blocks an admin from modifying their own record entirely (role or isActive) —
stricter than tenant-side, whose AdminService.update() does the same self-block but revoke() is a separate,
unguarded action. Combined here into one endpoint, so the self-block covers both. PlatformAdminRoleService.delete()
mirrors tenant-side’s exact business rule: blocked with 400 while any active admin is still assigned that role.
Bootstrap script — the first platform admin. Mirrors src/seed.ts/DefaultAdminSeed exactly:
DefaultPlatformAdminSeed (src/platform-admin/seed/), run via npm run seed:platform-admin
(node dist/seed-platform-admin in prod), reads DEFAULT_PLATFORM_ADMIN_EMAIL/DEFAULT_PLATFORM_ADMIN_PASSWORD_HASH
(generate the hash with the same npm run hash:password — already fully generic, no platform-specific variant
needed), skips if either is unset or if any platform_admins row already exists (idempotent — safe to leave in a
deploy pipeline), and seeds the admin with a find-or-create Platform Super Admin role holding every
PlatformAdminPermission. The AddPlatformAdminRoles migration also seeds this same role directly (originally
named SuperAdmin, see rename note below) and backfills any pre-migration platform_admins row onto it — the seed
script’s findOrCreateSuperAdmin() is what a fresh environment without that migration history hits.
Renamed from SuperAdmin to Platform Super Admin (1793044800000-RenamePlatformSuperAdminRole.ts): the
tenant-side AdminRole (one church, seeded by AdminRoleService.findOrCreateSuperAdmin/
TenantProvisioningService.seedTenantAdmin) and this platform-side PlatformAdminRole were both independently
named the literal string SuperAdmin — indistinguishable by name alone across two very different scopes (one
church vs. every tenant plus billing/impersonation). Renamed the platform side only, since it’s a single
control-plane table with few rows, versus the tenant-side name every existing church’s primary admin already sees.
findOrCreateSuperAdmin() is self-healing: it looks for Platform Super Admin first, then falls back to renaming
a legacy SuperAdmin row in place if the migration hasn’t run yet in that environment, rather than ever creating a
duplicate.
Platform Analytics (GET /platform/analytics/*)
Cross-tenant business metrics for whoever operates the platform itself — “how is the whole business doing,” not any
one church’s data. PlatformAnalyticsService (src/platform-admin/service/platform-analytics.service.ts) is
deliberately every method a live query, no new aggregation table or cron: tenant_rollups,
billing_checkout_sessions, and subscriptions are already small (one row per tenant, or one row per checkout) at
any realistic tenant count, so a SUM/GROUP BY at request time is cheap. Revisit only if tenant count genuinely
grows large enough to matter.
Trend bucketing (growth/revenue/churn): raw timestamped rows are fetched within a bounded window
(?months=, default 12, max 36) and bucketed in-memory by ?period=daily|weekly|monthly (default monthly) —
same in-JS-bucketing convention ServiceHeadcountService’s own trend endpoint already established, not a SQL
date_trunc. Weekly buckets label by the Sunday of that week; monthly buckets label YYYY-MM.
What’s a real trend vs. a snapshot: tenant signups (growth), revenue (revenue), and cancellations (churn)
are genuine time series — tenants.createdAt, billing_checkout_sessions.completedAt, and
subscriptions.canceledAt (added this pass — see below) are all real timestamps. Active-vs-suspended tenant counts
are not a trend — tenants.isActive is a plain boolean with no historical event log behind it, so growth
reports it as a current snapshot (currentActiveTenants/currentSuspendedTenants), not a fabricated time series.
Subscription.canceledAt (new column, src/migrations/1791072000000-AddSubscriptionCanceledAt.ts): set exactly
once, by CheckoutService.applySubscriptionCanceled(). Added specifically because updatedAt can’t be trusted for
“when this subscription was canceled” — it changes on any field update, not just a cancellation.
| Method | Route | Description |
|---|---|---|
| GET | /platform/analytics/overview |
{ totalTenants, activeTenants, suspendedTenants, totalMembersPlatformWide, subscriptionsByPlan[], mrrByCurrency: [{currency, mrrCents}] } — headline numbers. mrrByCurrency replaced a single blended mrrCents figure (breaking change) once plans could be priced in more than one currency |
| GET | /platform/analytics/growth |
?period=&months= — { period, signups: [{periodLabel, count}], currentActiveTenants, currentSuspendedTenants } |
| GET | /platform/analytics/revenue |
?period=&months= — { period, mrrByCurrency: [{currency, mrrCents}], revenueByProvider: [{provider, totalCents}], trend: [{periodLabel, subscriptionRevenueCents, totalCents}] } — only completed BillingCheckoutSession rows count (subscriptions only — wallet_topup no longer exists as a checkout type) |
| GET | /platform/analytics/engagement |
{ totalMembers, averageAttendanceRate, totalGiving, tenantsWithRollup, tenantsMissingRollup, oldestComputedAt, newestComputedAt } — sourced entirely from tenant_rollups (§Branch Hierarchy); oldest/newestComputedAt signal staleness since the rollup cron runs once daily |
| GET | /platform/analytics/churn |
?period=&months= — { period, currentlyCanceled, currentlyPastDue, trend: [{periodLabel, canceledCount}] } |
| GET | /platform/analytics/adoption |
{ smsAdoption: {byokCount, totalTenants, ratePercent}, emailAdoption: {...}, planDistribution: [{planId, planName, count}] } — BYOK adoption counts distinct tenants with an active TenantCommunicationProviderConfig per channel |
MRR calculation (overview and revenue): SUM(plan.priceCents) over every ACTIVE subscription, joined to its
plan — a tenant on the free plan contributes 0 naturally, no special-casing needed. This is current recurring
revenue (what’s active right now), distinct from revenue’s trend, which is realized revenue from completed
checkouts over time — the two can disagree (e.g. a subscription active today whose original checkout completed
outside the requested ?months= window).
6. API Endpoints Quick Reference
All routes are prefixed with
/v1/via NestJS URI versioning (defaultVersion: '1'). For example,POST /auth/loginis accessed asPOST /v1/auth/login. Future endpoint versions can be declared with@Version('2')at the controller or method level without affecting existing routes.
| Method | Route | Role | Description |
|---|---|---|---|
| GET | /health | Public | Liveness check used by Fly every 30s — probes Redis only (no database query, so it can’t stop Neon scaling to zero); 503 if Redis is unreachable. Exempt from rate limiting (@SkipThrottle). |
| GET | /health/deep | Public | Also probes the database (wakes it if scaled to zero); 503 listing whatever is unreachable. For manual checks or a low-frequency monitor. |
| POST | /auth/signup | Public | Register new member (server generates temp password; emailed to user) |
| POST | /auth/login | Public | Mobile app login — requires deviceId; enforces one-device-per-account lock |
| POST | /auth/admin-login | Public | Admin portal login — verifies active Admin record; no device check |
| POST | /auth/refresh | Public | Exchange refresh token |
| POST | /auth/logout | Any | Invalidate session |
| GET | /auth/me | Any | Own profile. Includes isHod: boolean — true if the authenticated member has a row in department_leads; clergy: {title: {id, name}, canReviewFeedback} | null; and isTrainee: boolean — mirrors workerProfile.isTrainee (false for non-workers). Clients should fetch this once on load to drive HOD/trainee-gated UI. |
| POST | /auth/change-password | Any | Change password (required when requires_password_change is true) |
| POST | /auth/email-change/request | Any (JwtAuthGuard) | Body { newEmail } — sends a 6-digit OTP to the new address; rate-limited; 409 if already in use by another member |
| POST | /auth/email-change/confirm | Any (JwtAuthGuard) | Body { otp } — verifies OTP, updates own email, sends a confirmation email |
| POST | /auth/forgot-password | Public | Request OTP reset code (rate-limited) |
| POST | /auth/reset-password | Public | Verify OTP and set new password; invalidates current session |
| POST | /auth/device-reset/request | Public | Self-service device reset — rate-limited; issues OTP to registered email; locks in newDeviceId at request |
| POST | /auth/device-reset/verify | Public | Verify OTP and swap deviceId to newDeviceId; invalidates all active sessions |
| POST | /auth/webauthn/login/options | Public | Biometric login, step 1 — no email needed (allowCredentials omitted); returns { challengeId, options }; IP-throttled (10/min) |
| POST | /auth/webauthn/login/verify | Public | Biometric login, step 2 — body { challengeId, response }; resolves the member from the credential and issues tokens via the same path password login uses |
| POST | /auth/webauthn/register/options | Any (JwtAuthGuard) | Enroll a new device, step 1 — returns resident-key (residentKey: 'required') registration options for the calling member |
| POST | /auth/webauthn/register/verify | Any (JwtAuthGuard) | Enroll a new device, step 2 — body is the browser’s RegistrationResponseJSON; stores the new credential, 204 on success |
| GET | /auth/webauthn/credentials | Any (JwtAuthGuard) | List the caller’s own registered devices — { id, deviceName, createdAt, lastUsedAt }[], never the credential id/public key |
| DELETE | /auth/webauthn/credentials/:id | Any (JwtAuthGuard) | Remove one of the caller’s own devices; 404 if it doesn’t belong to them; 204 on success |
| PATCH | /members/me | Any (JwtAuthGuard) | Self-service profile edit: firstname, lastname, phoneNumber, gender, birthDay, birthMonth, birthYear, maritalStatus, dateJoinedChurch, yearBornAgain, yearBaptized, baptizedWithHolyGhost; workers can also set their own profession and yearJoinedWorkforce (ignored for non-workers; a future year is rejected) (excludes email) |
| POST | /members/me/serve-interest | Any (JwtAuthGuard) | Record “I’d like to serve” (serveInterestAt); 400 for workers |
| DELETE | /members/me/serve-interest | Any (JwtAuthGuard) | Withdraw the serve request |
| DELETE | /members/:id/serve-interest | AdminGuard (MEMBERS_WRITE) | Dismiss a member’s serve request (declined); idempotent, audited. The member can request again |
| POST | /members/me/photo | Any (JwtAuthGuard) | Upload/replace own profile photo — multipart field photo, image mimetypes only, 3MB limit |
| DELETE | /members/me/photo | Any (JwtAuthGuard) | Remove own profile photo |
| DELETE | /members/:id/photo | AdminGuard (MEMBERS_WRITE) | Moderation — clear a member’s profile photo |
| GET | /members?page=&limit=&role=&search=&wantsToServe= | AdminGuard (MEMBERS_READ) | List members — filterable by role; search matches firstname, lastname, email, or phone (case-insensitive); wantsToServe=true returns only active members with a pending serve request |
| POST | /members | AdminGuard (MEMBERS_WRITE) | Create a plain MEMBER account directly (body: SignupDto) — shares signup()'s temp-password/forced-change-password flow; audit-logged as MEMBER_CREATED_BY_ADMIN |
| GET | /members/workers | AdminGuard (MEMBERS_READ) | List workers (filterable by status) |
| GET | /members/:id | AdminGuard (MEMBERS_READ) | Get member by ID |
| PATCH | /members/:id | AdminGuard (MEMBERS_WRITE) | Update member details |
| POST | /members/bulk-promote | AdminGuard (MEMBERS_WRITE) | Bulk promote members to workers; returns { promoted, skipped, failures: [{ memberId, reason }] } |
| POST | /members/:id/promote | AdminGuard (MEMBERS_WRITE) | Promote member to worker — reactivates a prior INACTIVE WorkerProfile (resumes department/progress) if one exists, else creates new. Only departmentId is required; profession/yearJoinedWorkforce are optional (omit them rather than sending empty strings) and the worker is prompted in the member app to add them |
| POST | /members/:id/revoke-worker | AdminGuard (MEMBERS_WRITE) | Remove worker role (deactivates WorkerProfile to INACTIVE; row is kept, not deleted) |
| POST | /members/:id/demote-trainee | AdminGuard (MEMBERS_WRITE) | Demote a trainee worker to MEMBER (keeps WorkerProfile as INACTIVE history; 400 if not a trainee) |
| PATCH | /members/:id/worker-profile | AdminGuard (MEMBERS_WRITE) | Update worker profile (incl. isTrainee) |
| PATCH | /members/:id/status | AdminGuard (MEMBERS_WRITE) | Activate/deactivate member |
| POST | /members/:id/reset-password | AdminGuard (MEMBERS_WRITE) | Reset & email new password |
| DELETE | /members/:id/device | AdminGuard (MEMBERS_WRITE) | Purge device lock; invalidates all active sessions |
| POST | /members/:id/clergy | AdminGuard (MEMBERS_WRITE) | Assign clergy designation, body { clergyTitleId }; 409 if already clergy, 404 if the title is unknown |
| PATCH | /members/:id/clergy | AdminGuard (MEMBERS_WRITE) | Change clergy title, body { clergyTitleId }; 404 if not clergy, or if the title is unknown |
| DELETE | /members/:id/clergy | AdminGuard (MEMBERS_WRITE) | Remove clergy designation; 404 if not clergy; returns 204 |
| POST | /members/:id/spouse | AdminGuard (MEMBERS_WRITE) | Link two members as spouses (symmetric), body { spouseId }; 400 if either side already has a spouse or spouseId is the member’s own id |
| DELETE | /members/:id/spouse | AdminGuard (MEMBERS_WRITE) | Unlink spouse on both sides; 400 if no spouse is linked; returns 204 |
| PATCH | /members/:id/clergy/review-access | AdminGuard (MEMBERS_WRITE) | Grant/revoke Pastor Feedback review access, body { canReviewFeedback }, independent of title; 404 if not clergy |
| GET | /clergy-titles | Public | Tenant’s clergy-title catalog, see ClergyTitle above |
| GET | /clergy-titles/:id | Public | Single clergy title |
| POST | /clergy-titles | AdminGuard (MEMBERS_WRITE) | Create a clergy title, body { name, description? }; 400 if name in use |
| PATCH | /clergy-titles/:id | AdminGuard (MEMBERS_WRITE) | Update a clergy title |
| DELETE | /clergy-titles/:id | AdminGuard (MEMBERS_WRITE) | 400 if any clergy member is still assigned to it |
| GET | /members/bulk-import/template | AdminGuard (MEMBERS_WRITE) | Streams a .xlsx bulk-import template |
| POST | /members/bulk-import/preview | AdminGuard (MEMBERS_WRITE) | Multipart file upload (5 MB cap); validates every row, persists a MemberImportJob + rows, returns { ...job, rows } |
| GET | /members/bulk-import/:jobId | AdminGuard (MEMBERS_WRITE) | Refetch a previously-previewed import job and its rows |
| POST | /members/bulk-import/:jobId/commit | AdminGuard (MEMBERS_WRITE) | Create a Member (+ WorkerProfile if department was filled) for every valid row; returns { createdCount, failedRows } |
| GET | /admin/roles | AdminGuard (ADMIN_READ) | List admin roles |
| GET | /admin/roles/:id | AdminGuard (ADMIN_READ) | Get admin role by ID |
| POST | /admin/roles | AdminGuard (ADMIN_WRITE) | Create admin role |
| PATCH | /admin/roles/:id | AdminGuard (ADMIN_WRITE) | Update admin role |
| DELETE | /admin/roles/:id | AdminGuard (ADMIN_WRITE) | Delete admin role |
| GET | /admin/users | AdminGuard (ADMIN_READ) | List admin users |
| GET | /admin/users/me | AdminGuard | Own admin profile (includes favouritePages) |
| PUT | /admin/users/me/favourite-pages | AdminGuard (any admin) | Replace own pinned pages. Body { pages: string[] } — up to 12 admin-portal paths (/members, /finances/external-payees), order kept, duplicates dropped. Returns { favouritePages } |
| GET | /admin/users/:id | AdminGuard (ADMIN_READ) | Get admin user by ID |
| POST | /admin/users | AdminGuard (ADMIN_WRITE) | Grant admin access to a member |
| PATCH | /admin/users/:id | AdminGuard (ADMIN_WRITE) | Update admin user role/status |
| POST | /admin/users/:id/revoke | AdminGuard (ADMIN_WRITE) | Revoke admin access |
| GET | /admin/audit-logs | AdminGuard (AUDIT_READ) | Paginated audit log; filterable by action, actorId, targetId, dateFrom, dateTo |
| POST | /attendances/checkin | Any | Check in to a service slot (workers must include location when the resolved slot format is IN_PERSON; not required for ONLINE; one record per event per member) |
| GET | /attendances/me/distance-check | Any (JwtAuthGuard) | { enabled, isPlatformDefault } — member-readable mirror of the admin distance-check setting below, so the member app can skip its own client-side distance block when enforcement is off |
| GET | /attendances/my-history | Any | Own attendance records |
| GET | /attendances/my-summary | Any | Own lifetime rate/streak, computed in SQL over full history (not just the current page) — { totalCount, presentCount, attendanceRatePercentage, lastCheckedInDate, attendanceStreak } |
| GET | /attendances/history | AdminGuard (ATTENDANCE_READ) | All attendance records; query: page, limit, memberId, slotId, status, dateFrom, dateTo, search (ILIKE on firstname, lastname, email), role (MEMBER|WORKER — filters Attendance.roleAtCheckin, the role snapshotted at check-in time, not the member’s current role) |
| POST | /attendances/export-email | AdminGuard (ATTENDANCE_READ) | Email the currently-filtered attendance history as an .xlsx attachment (body: recipientEmail?, memberId?, slotId?, status?, dateFrom?, dateTo?, search?, role?). recipientEmail defaults to the requesting admin’s own email. One-off only — not a recurring/scheduled report. Logs REPORT_EXPORTED. |
| GET | /attendances/history/department?slotId=&page=&limit= | WORKER | Paginated department attendance for a slot (scoped to caller’s own department via lead role); page defaults to 1, limit to 20 |
| GET | /attendances/department/event/:eventId | WORKER | Worker attendance for all slots of an event (scoped to caller’s own department via lead role) |
| GET | /attendances/summary/slot/:slotId | AdminGuard (ATTENDANCE_READ) | Status counts for a slot |
| GET | /attendances/leaderboard | AdminGuard (ATTENDANCE_READ) | Top workers by attendance |
| PATCH | /attendances/:id/correct | AdminGuard (ATTENDANCE_WRITE) | Admin correction of an attendance record status |
| GET | /attendances/at-risk?minAbsences=&from=&to=&page=&limit= | AdminGuard (ATTENDANCE_READ) | Members with ≥ N ABSENT records in range; returns absenceCount, lastSeenAt, hasOpenFollowUpTask |
| POST | /attendances/admin/mark | AdminGuard (ATTENDANCE_WRITE) | Create/backfill an attendance record for any member+event — no-phone check-in and streak restore, body { memberId, serviceSlotId, status } |
| POST | /attendances/department/mark | JwtAuthGuard (Admin-department worker) | Same action, mobile-reachable for Admin-department front-desk workers; same body |
| GET | /attendances/department/search-members?q= | JwtAuthGuard (Admin-department worker) | Narrow member lookup (≤10 results, id/firstname/lastname/role only) backing the mobile check-in picker |
| POST | /attendances/online-confirm | JwtAuthGuard (any authenticated member) | Confirm online attendance for an event (updates ABSENT → ATTENDED_ONLINE within window) |
| POST | /follow-up/first-timers | WORKER (FOLLOW_UP dept) | Register a first-timer (auto-creates FollowUpTask via round-robin) |
| POST | /follow-up/first-timers/:id/link-convert | WORKER (FOLLOW_UP dept) | Confirm a suggested outreach convert { convertId } |
| POST | /follow-up/first-timers/:id/dismiss-convert | WORKER (FOLLOW_UP dept) | Dismiss a suggested outreach convert { convertId } |
| DELETE | /follow-up/first-timers/:id/link-convert | WORKER (FOLLOW_UP dept) | Unlink the outreach convert |
| GET | /follow-up/tasks/mine | WORKER (FOLLOW_UP dept) | List follow-up tasks assigned to the caller |
| PATCH | /follow-up/tasks/:id | WORKER (FOLLOW_UP dept) | Update task status/outcome/notes+contactMethod (caller must be the assignee); sets lastActivityAt |
| POST | /follow-up/tasks/:id/notes | WORKER (FOLLOW_UP dept) | Add a note (with optional contactMethod) without changing task status; sets lastActivityAt |
| POST | /admin/follow-up/first-timers | AdminGuard (FOLLOW_UP_WRITE) | Register a first-timer from admin portal |
| GET | /admin/follow-up/first-timers | AdminGuard (FOLLOW_UP_READ) | List first-timers; query: page, limit, eventId, source, wantsToJoinChurch, wantsToJoinWorkforce, search, dateFrom, dateTo (YYYY-MM-DD) |
| GET | /admin/follow-up/first-timers/pipeline | AdminGuard (FOLLOW_UP_READ) | Funnel counts: { total, untouched, contacted, returned, invited, converted }. Optional from/to filter. |
| POST | /admin/follow-up/first-timers/:id/visits | AdminGuard (FOLLOW_UP_WRITE) | Log a return visit. Body: { eventId?, notes?, visitedAt? } — visitedAt defaults to today (YYYY-MM-DD) |
| GET | /admin/follow-up/tasks | AdminGuard (FOLLOW_UP_READ) | List follow-up tasks; query: page, limit, status, type, search (matches first-timer name) |
| GET | /admin/follow-up/tasks/stale | AdminGuard (FOLLOW_UP_READ) | Open tasks with no activity for ≥ daysInactive (default 7) days; paginated, ordered by oldest activity |
| PATCH | /admin/follow-up/tasks/:id/reassign | AdminGuard (FOLLOW_UP_WRITE) | Reassign a task to a different FOLLOW_UP-dept worker |
| PATCH | /admin/follow-up/tasks/bulk | AdminGuard (FOLLOW_UP_WRITE) | Bulk update task statuses |
| POST | /admin/follow-up/first-timers/:id/invite-to-membership | AdminGuard (FOLLOW_UP_WRITE) | Queue membership invitation email. Returns { queued: true/false }. Deduped by inviteSentAt. |
| PATCH | /admin/follow-up/first-timers/:id/mark-converted | AdminGuard (FOLLOW_UP_WRITE) | Mark first-timer as converted; optional { memberId } body links to their Member record (and marks a linked outreach convert joined) |
| POST | /admin/follow-up/first-timers/:id/link-convert | AdminGuard (FOLLOW_UP_WRITE) | Confirm a suggested outreach convert is this first-timer { convertId }; Follow-Up takes over |
| POST | /admin/follow-up/first-timers/:id/dismiss-convert | AdminGuard (FOLLOW_UP_WRITE) | “Not them” — stop suggesting { convertId } for this first-timer |
| DELETE | /admin/follow-up/first-timers/:id/link-convert | AdminGuard (FOLLOW_UP_WRITE) | Undo a wrong outreach match; convert returns to Evangelism unassigned |
| PATCH | /admin/follow-up/tasks/:id | AdminGuard (FOLLOW_UP_WRITE) | Admin update of any task: status, outcome, outcomeNotes, dueDate, noteContent, contactMethod |
| GET | /admin/follow-up/report | AdminGuard (FOLLOW_UP_READ) | Pastoral report: first-timer totals, task stats, overdue count, conversion rate, by-worker, by-event |
| POST | /evangelism/converts | WORKER | Add a convert (only name required); optional outreachId, allowDuplicate. 409 CONVERT_DUPLICATE on a known phone |
| POST | /evangelism/converts/:id/met-again | WORKER | Log a “Met again” follow-up on an existing convert (duplicate path) |
| GET | /evangelism/converts?scope=mine|team&…filters… | JwtAuthGuard (team needs Evangelism capability) |
My converts (added / on the outreach team / assigned) or the whole team list, with staleness + myRoles |
| POST | /evangelism/converts/:id/follow-up | Outreach team, assignee or Evangelism dept | Log a follow-up contact |
| PATCH | /evangelism/converts/:id/status | Outreach team, assignee or Evangelism dept | Update convert status |
| GET | /evangelism/converts/:id/follow-up-history?page=&limit= | Outreach team, assignee or Evangelism dept | Full follow-up log for a convert, newest first (mobile) |
| GET | /evangelism/workers?q= | WORKER | Active workers for team pickers (excludes caller) |
| POST | /evangelism/outreaches | WORKER | Start an outreach { title?, location?, date?, teamMemberIds }; creator always on the team |
| GET | /evangelism/outreaches/recent | WORKER | Outreaches the caller is on, last 14 days |
| PATCH | /evangelism/outreaches/:id/team | WORKER (on the team) | Replace the team (creator kept); pushes newly added members |
| GET | /evangelism/converts/admin?…filters…&stage= | AdminGuard (EVANGELISM_READ) | Cross-member browse (admin portal); stage=open|with_follow_up|joined |
| GET | /evangelism/converts/admin/workers?q= | AdminGuard (EVANGELISM_READ) | Active workers with Evangelism flag + open load, for assignee/team pickers |
| PATCH | /evangelism/converts/admin/bulk-reassign | AdminGuard (EVANGELISM_WRITE) | Move convertIds or all of fromWorkerProfileId’s open converts to toWorkerProfileId |
| PATCH | /evangelism/converts/admin/:id/reassign | AdminGuard (EVANGELISM_WRITE) | Assign follow-up to any active worker |
| PATCH | /evangelism/converts/admin/:id/unassign | AdminGuard (EVANGELISM_WRITE) | Clear the assignee |
| PATCH | /evangelism/converts/admin/:id/outreach | AdminGuard (EVANGELISM_WRITE) | Move a convert to another outreach, or null to detach |
| PATCH | /evangelism/converts/admin/:id/link-member | AdminGuard (EVANGELISM_WRITE) | Link a convert to their new Member record |
| GET | /evangelism/converts/admin/:id/follow-up-history?page=&limit= | AdminGuard (EVANGELISM_READ) | Full follow-up log for a convert, newest first (admin portal) |
| GET | /evangelism/outreaches/admin?from=&to= | AdminGuard (EVANGELISM_READ) | Outreaches with teams (up to 200) |
| PATCH | /evangelism/outreaches/admin/:id/team | AdminGuard (EVANGELISM_WRITE) | Replace an outreach team |
| GET/PATCH | /evangelism/settings/admin | AdminGuard (EVANGELISM_READ / _WRITE) | overdueDays (1–90), autoAssign |
| GET | /evangelism/report?from=&to= | AdminGuard (EVANGELISM_READ) | Summary, trend, per-worker and per-outreach report |
| GET | /evangelism/export?type=converts|workers|outreaches&… | AdminGuard (EVANGELISM_READ) + plan BULK_EXPORT | CSV export |
| POST | /admin/sermons | AdminGuard (SERMON_WRITE) | Create a sermon archive entry — body: title, speakerName, date, description?, youtubeUrl?, mixlrUrl?, series?. 400 if neither youtubeUrl nor mixlrUrl is set. |
| GET | /admin/sermons?page=&limit=&series= | AdminGuard (SERMON_READ) | Paginated list, newest first, optional exact series filter |
| GET | /admin/sermons/:id | AdminGuard (SERMON_READ) | Get a single sermon |
| PATCH | /admin/sermons/:id | AdminGuard (SERMON_WRITE) | Update any field. 400 if the update would leave both youtubeUrl and mixlrUrl unset. |
| DELETE | /admin/sermons/:id | AdminGuard (SERMON_WRITE) | Delete a sermon archive entry |
| POST | /admin/sermons/announce-live | AdminGuard (SERMON_WRITE) | Manual “we’re live” trigger — body: { platform: 'YOUTUBE' \| 'MIXLR', url, title? }. Publishes an ALL-audience system announcement via AnnouncementService.createSystemAnnouncement() and push-notifies every active member. |
| GET | /sermons?page=&limit=&series= | JwtAuthGuard + Module: sermons | Paginated list for any authenticated member/worker — no department or class gating |
| GET | /sermons/:id | JwtAuthGuard + Module: sermons | Get a single sermon |
| GET | /sermons/:id/note | JwtAuthGuard + Module: sermons | Get the requesting member’s own private note for this sermon (null if none) — own data, no admin visibility |
| PUT | /sermons/:id/note | JwtAuthGuard + Module: sermons | Create or update the requesting member’s note for this sermon (body: { note }, upsert) |
| DELETE | /sermons/:id/note | JwtAuthGuard + Module: sermons | Delete the requesting member’s note for this sermon |
| GET | /notes | JwtAuthGuard + Module: notes | The member’s own notes, paginated (page, limit ≤ 50, kind, q, sermonId); pinned first, then newest |
| GET | /notes/context | JwtAuthGuard + Module: notes | The service on now or earlier today, with speaker, same-day sermon and the member’s note for it (null if none) |
| GET | /notes/streak | JwtAuthGuard + Module: notes | Weekly sermon-notes streak: { current, best, thisWeek } |
| GET | /notes/top-scriptures | JwtAuthGuard + Module: notes | Most-noted refs for an event (eventId), only refs noted by 3+ members |
| POST | /notes/scripture-taps | JwtAuthGuard + Module: notes | Add batched taps on bible.com version links ({ taps: [{ version, count }] }), 204 |
| GET | /notes/preferences | JwtAuthGuard + Module: notes | { nudges } — whether the member gets Notes reminders |
| PUT | /notes/preferences | JwtAuthGuard + Module: notes | Turn the member’s Notes reminders on or off ({ nudges }) |
| GET | /notes/services | JwtAuthGuard + Module: notes | Services from the last 35 days the member can link a note to, with attended and their existing noteId |
| GET | /notes/:id | JwtAuthGuard + Module: notes | One of the member’s own notes, with its linked service (404 for anyone else’s) |
| POST | /notes | JwtAuthGuard + Module: notes | Create a note (kind?, title?, content, sermonId?, serviceSlotId?); returns the existing note for a service |
| PATCH | /notes/:id | JwtAuthGuard + Module: notes | Update title, content, pinned, sermonId, serviceSlotId (null unlinks; 409 NOTE_SERVICE_TAKEN); baseUpdatedAt guards edits made elsewhere |
| DELETE | /notes/:id | JwtAuthGuard + Module: notes | Delete one of the member’s own notes |
| GET | /admin/notes/insights | AdminGuard + SERMON_READ + Module: notes | Totals only: notes and members in the last 30 days, bible.com version taps over 90 days |
| GET | /integrations/youtube/callback | No guard — WebSub verification handshake | Echoes hub.challenge for subscribe/unsubscribe modes; 404 otherwise. Called by Google’s PubSubHubbub hub, not a client. |
| POST | /integrations/youtube/callback | No guard — WebSub notification | Receives the “video published” Atom feed ping; always 204. Triggers YouTube Data API check + auto-announcement if actually live. Called by the hub, not a client. |
| POST | /admin/games | AdminGuard (GAMES_WRITE) | Create a game (DRAFT) |
| GET | /admin/games?page=&limit=&search=&status= | AdminGuard (GAMES_READ) | Paginated list, newest first. search ILIKE-matches title/description; status filters on the raw GameStatusEnum column. Each game carries activeSessionCode (non-null only while LIVE_SESSION_ACTIVE) and playCount (count of its ENDED sessions) |
| GET | /admin/games/:id | AdminGuard (GAMES_READ) | Get a single game, with activeSessionCode and playCount |
| PATCH | /admin/games/:id | AdminGuard (GAMES_WRITE) | Update title/description/department/churchClass |
| DELETE | /admin/games/:id | AdminGuard (GAMES_WRITE) | Delete a game (cascades questions/sessions/participants/responses) |
| GET | /admin/games/:id/questions | AdminGuard (GAMES_READ) | List a game’s questions, ordered |
| POST | /admin/games/:id/questions | AdminGuard (GAMES_WRITE) | Add a question — body: questionText, options (>=2), correctOptionIndex, points?, timeLimitSeconds?. 400 if correctOptionIndex is out of range. |
| PUT | /admin/games/:id/questions/reorder | AdminGuard (GAMES_WRITE) | Reorder — body: { questionIds: string[] }, must contain exactly the game’s current question ids |
| PATCH | /admin/games/questions/:questionId | AdminGuard (GAMES_WRITE) | Update a question (any field) |
| DELETE | /admin/games/questions/:questionId | AdminGuard (GAMES_WRITE) | Delete a question |
| POST | /admin/games/:id/start | AdminGuard (GAMES_WRITE) | Start a session into its lobby (LIVE, no current question yet) — 400 if the game has no questions or a LIVE session already exists for it. Caller becomes the session’s host. |
| POST | /admin/games/sessions/:code/next-question | AdminGuard (GAMES_WRITE) | Advance to the next question (from the lobby, reveals Question 1) — 403 if caller isn’t the host, 400 if session isn’t LIVE or already on the last question |
| POST | /admin/games/sessions/:code/end | AdminGuard (GAMES_WRITE) | End the session (idempotent) and revert the game to DRAFT — any GAMES_WRITE admin, not host-restricted |
| GET | /admin/games/sessions/:code/state | AdminGuard (GAMES_READ) | Current session state (same shape broadcast over the socket, no correctOptionIndex, includes currentQuestionStartedAt) |
| GET | /admin/games/sessions/:code/leaderboard | AdminGuard (GAMES_READ) | Live leaderboard, ordered by totalScore desc, createdAt asc tie-break |
| GET | /admin/games/:id/sessions?page=&limit= | AdminGuard (GAMES_READ) | Past + current sessions for a game, with participantCount/topScore/topScorerName per session |
| POST | /games/sessions/:code/join | JwtAuthGuard + Module: games | Join a live session with its code — upserts a GameParticipant, no department/class gating |
| GET | /games/sessions/:code/state | Public + Module: games, throttled 300/60s | Current session state — same payload the socket broadcasts. Unauthenticated so the projector/screen presentation view can poll it with just the join code. |
| POST | /games/sessions/:code/questions/:questionId/answer | JwtAuthGuard + Module: games | Submit an answer — body: { selectedOptionIndex }. 400 if not the current question, already answered, or past the time limit + grace; 403 if caller never joined. |
| GET | /games/sessions/:code/leaderboard | JwtAuthGuard + Module: games | Live leaderboard |
| GET | /games/my-history?page=&limit= | JwtAuthGuard + Module: games | Caller’s own past (ENDED) sessions with score, rank, participantCount, correct/answered counts |
| POST | /service-ratings | JwtAuthGuard + Module: service_ratings | Submit or update a rating for a service (body: eventId, serviceSlotId, rating 1–5, comment?) — upsert |
| GET | /service-ratings/mine?eventId=&serviceSlotId= | JwtAuthGuard + Module: service_ratings | The requesting member’s own rating for a service, or null |
| GET | /admin/service-ratings/summary?eventId=&from=&to= | AdminGuard (SERVICE_RATING_READ) | Average rating, total count, and 1–5 star distribution |
| GET | /admin/service-ratings/comments?page=&limit= | AdminGuard (SERVICE_RATING_READ) | Paginated comment feed, anonymized unless the admin also has SERVICE_RATING_MODERATE |
| DELETE | /admin/service-ratings/:id | AdminGuard (SERVICE_RATING_MODERATE) | Delete/hide a rating; logs SERVICE_RATING_MODERATED |
| POST | /admin/volunteer-opportunities | AdminGuard (VOLUNTEER_WRITE) | Create an opportunity — body: title, description?, departmentId?, date, capacity? (omit for unlimited) |
| GET | /admin/volunteer-opportunities?page=&limit=&search=&status= | AdminGuard (VOLUNTEER_READ) | Paginated list, newest date first. search ILIKE-matches title/description; status filters on VolunteerOpportunityStatusEnum |
| PATCH | /admin/volunteer-opportunities/:id | AdminGuard (VOLUNTEER_WRITE) | Update any field |
| PATCH | /admin/volunteer-opportunities/:id/cancel | AdminGuard (VOLUNTEER_WRITE) | Cancel an opportunity (status → CANCELLED); no hard delete |
| GET | /admin/volunteer-opportunities/:id/signups | AdminGuard (VOLUNTEER_READ) | Roster — CONFIRMED signups with member names |
| GET | /volunteer-opportunities?page=&limit= | JwtAuthGuard + Module: volunteering | Open, upcoming opportunities; each row includes the caller’s own mySignupStatus |
| POST | /volunteer-opportunities/:id/signup | JwtAuthGuard + Module: volunteering | Sign up (upsert — re-signing after a cancel re-confirms the same row). 400 if not OPEN or at capacity. |
| DELETE | /volunteer-opportunities/:id/signup | JwtAuthGuard + Module: volunteering | Cancel the caller’s own signup |
| POST | /admin/small-groups | AdminGuard (SMALL_GROUP_WRITE) | Create a group — body: name, description?, leaderId?, meetingDay?, meetingLocation?, venueId?, meetingFormat?, meetingLink? |
| GET | /admin/small-groups?page=&limit=&search=&meetingFormat= | AdminGuard (SMALL_GROUP_READ) | Paginated list, alphabetical by name. search ILIKE-matches name/description; meetingFormat filters on MeetingFormatEnum |
| PATCH | /admin/small-groups/:id | AdminGuard (SMALL_GROUP_WRITE) | Update any field; leaderId: null/venueId: null explicitly unassigns the leader/venue |
| DELETE | /admin/small-groups/:id | AdminGuard (SMALL_GROUP_WRITE) | Hard delete — removes membership and attendance history too (CASCADE) |
| GET | /admin/small-groups/:id/members | AdminGuard (SMALL_GROUP_READ) | Full roster |
| DELETE | /admin/small-groups/:id/members/:memberId | AdminGuard (SMALL_GROUP_WRITE) | Force-remove a member; logs SMALL_GROUP_MEMBER_REMOVED |
| GET | /admin/small-groups/:id/attendance | AdminGuard (SMALL_GROUP_READ) | Full attendance history, newest meeting date first |
| GET | /small-groups?page=&limit= | JwtAuthGuard + Module: small_groups | Browse all groups with memberCount (not the roster itself) |
| GET | /small-groups/mine | JwtAuthGuard + Module: small_groups | Groups the caller currently belongs to |
| GET | /small-groups/:id | JwtAuthGuard + Module: small_groups | Group detail |
| GET | /small-groups/:id/members | JwtAuthGuard + Module: small_groups | Full roster — requires the caller to currently be a member of this group |
| POST | /small-groups/:id/join | JwtAuthGuard + Module: small_groups | Self-join (upsert — re-joining after leaving works) |
| DELETE | /small-groups/:id/leave | JwtAuthGuard + Module: small_groups | Self-leave |
| POST | /small-groups/:id/attendance | JwtAuthGuard + Module: small_groups | Record attendance — body: { meetingDate, records: [{memberId, status}] }. 403 unless the caller is this group’s leader. |
| POST | /events | AdminGuard (EVENTS_WRITE) | Create event (single or recurring). recurrence.ongoing: true makes an open-ended series (no recurrenceEndDate); autoProgramme: false skips draft programmes from templates |
| PATCH | /events/:id | AdminGuard (EVENTS_WRITE) | Update event |
| GET | /events/:id | Any | Get event by ID |
| GET | /events | Any | List events. Query: page, limit, orderBy, order, from (YYYY-MM-DD), to (YYYY-MM-DD), upcoming=true, search (case-insensitive match on event name — powers searchable event pickers in the admin frontend) |
| DELETE | /events/:id | AdminGuard (EVENTS_WRITE) | Delete single event — blocked if attendanceMarked = true or event is in the past |
| DELETE | /events/recurring/:recurringEventId | AdminGuard (EVENTS_WRITE) | Delete future recurring events (also deactivates the series) |
| GET | /events/series | AdminGuard (EVENTS_READ) | Active series with nextOccurrence and upcomingCount |
| GET | /events/series/:id | AdminGuard (EVENTS_READ) | One series (404 for pre-series recurring groups) |
| PATCH | /events/series/:id | AdminGuard (EVENTS_WRITE) | Edit from effectiveFrom — body: name?, description?, onlineAttendanceEnabled?, autoProgramme?, slotBlueprint?, effectiveFrom, confirmRecreate?. 409 SERIES_RECREATE_REQUIRED when services are added/removed |
| POST | /events/series/:id/stop | AdminGuard (EVENTS_WRITE) | Stop repeating — body { from }; removes upcoming dates without history, returns { removed } |
| GET | /events/templates | AdminGuard (EVENTS_READ) | Saved service types, by name (with audienceGroup) |
| GET | /events/audience-groups | AdminGuard (EVENTS_WRITE) | Groups an event can be for — [{ id, name }], by name |
| GET | /attendances/settings/online-window | AdminGuard (ATTENDANCE_READ) | Online-attendance confirmation window — { minutes, isDefault } |
| PATCH | /attendances/settings/online-window | AdminGuard (ATTENDANCE_WRITE) | Set the window — body { minutes } (15–10080); applies to the next service’s emails |
| POST | /events/templates | AdminGuard (EVENTS_WRITE) | Save a service type — body: name, description?, onlineAttendanceEnabled?, slotBlueprint, defaultRecurrence?, autoProgramme? |
| PATCH | /events/templates/:id | AdminGuard (EVENTS_WRITE) | Replace a service type (same body as POST) |
| DELETE | /events/templates/:id | AdminGuard (EVENTS_WRITE) | Delete a service type |
| POST | /event-config | AdminGuard (EVENTS_WRITE) | Create timing config — body gains defaultFormat? (IN_PERSON|ONLINE), onlineMeetingUrl?; defaultVenueId is now optional (required only when defaultFormat is IN_PERSON) |
| PATCH | /event-config/:id | AdminGuard (EVENTS_WRITE) | Update timing config — defaultVenueId: null explicitly clears the venue (needed when switching to ONLINE) |
| GET | /event-config/:id | AdminGuard (EVENTS_WRITE) | Get config by ID |
| GET | /event-config | AdminGuard (EVENTS_WRITE) | List configs |
| DELETE | /event-config/:id | AdminGuard (EVENTS_WRITE) | Delete config |
| POST | /events/slots/:slotId/reminders | AdminGuard (EVENTS_WRITE) | Add a reminder schedule to a slot |
| GET | /events/slots/:slotId/reminders | AdminGuard (EVENTS_WRITE) | List reminders for a slot |
| PATCH | /events/slots/:slotId/reminders/:reminderId | AdminGuard (EVENTS_WRITE) | Update reminder (audience, preset, enabled) |
| DELETE | /events/slots/:slotId/reminders/:reminderId | AdminGuard (EVENTS_WRITE) | Delete reminder |
| POST | /venues | AdminGuard (VENUES_WRITE) | Create venue |
| PATCH | /venues/:id | AdminGuard (VENUES_WRITE) | Update venue |
| DELETE | /venues/:id | AdminGuard (VENUES_WRITE) | Delete venue |
| GET | /venues | Any | List venues |
| GET | /venues/nearby | Any | Find nearby venues by radius |
| GET | /venues/:id | Any | Get venue by ID |
| GET | /departments | Any | List departments |
| GET | /departments/capabilities | Any | List all valid capabilities as { value, label }[] (the shared EnumOption shape used across /enums) — label is the human-readable description from DepartmentCapabilityLabels, for admin-UI display |
| GET | /departments/:id | Any | Get department |
| POST | /departments | AdminGuard (DEPARTMENTS_WRITE) | Create department |
| PATCH | /departments/:id | AdminGuard (DEPARTMENTS_WRITE) | Update department |
| DELETE | /departments/:id | AdminGuard (DEPARTMENTS_WRITE) | Delete department |
| POST | /departments/:id/bulk-assign | AdminGuard (DEPARTMENTS_WRITE) | Bulk assign workers to a primary department; returns { updated, skipped } |
| POST | /departments/assign-lead | AdminGuard (DEPARTMENTS_WRITE) | Assign head/assistant lead (accepts primary OR secondary department membership) |
| POST | /departments/remove-lead | AdminGuard (DEPARTMENTS_WRITE) | Remove lead |
| GET | /departments/leads/:id | AdminGuard (DEPARTMENTS_READ) | Leads for a department |
| GET | /departments/leads | AdminGuard (DEPARTMENTS_READ) | All department leads |
| GET | /departments/:id/workers | AdminGuard (DEPARTMENTS_READ) | List workers in a department (paginated) |
| GET | /departments/my/summary | WORKER | Own department summary (lead only) |
| POST | /pastor-feedback | JwtAuthGuard (HOD/D_HOD of departmentId) | Submit weekly pastor feedback |
| PATCH | /pastor-feedback/:id | JwtAuthGuard (must be the submitter) | Edit own submission |
| GET | /pastor-feedback/my?page=&limit= | JwtAuthGuard | Own submission history |
| GET | /pastor-feedback/admin?departmentId=&weekOf=&page=&limit= | AdminGuard (PASTOR_FEEDBACK_READ) | Cross-department browse |
| GET | /pastor-feedback/admin/department/:departmentId | AdminGuard (PASTOR_FEEDBACK_READ) | One department’s submission history |
| PATCH | /pastor-feedback/admin/:id | AdminGuard (PASTOR_FEEDBACK_WRITE) | Edit a submission on the HOD’s behalf |
| DELETE | /pastor-feedback/admin/:id | AdminGuard (PASTOR_FEEDBACK_WRITE) | Delete a submission |
| POST | /pastor-feedback/admin/:id/respond | AdminGuard (PASTOR_FEEDBACK_WRITE) | Respond as pastor (requires admin’s linked Member to have a Pastor record) |
| GET | /pastor-feedback/pastor?departmentId=&weekOf=&page=&limit= | JwtAuthGuard (Pastor record required) | Cross-department browse (mobile) |
| GET | /pastor-feedback/pastor/department/:departmentId | JwtAuthGuard (Pastor record required) | One department’s submission history (mobile) |
| POST | /pastor-feedback/pastor/:id/respond | JwtAuthGuard (Pastor record required) | Respond as pastor (mobile) |
| POST | /prayer-requests | Any (JwtAuthGuard) | Submit a private prayer request |
| GET | /prayer-requests/mine?page=&limit= | Any (JwtAuthGuard) | Own prayer request history |
| POST | /testimonies | Any (JwtAuthGuard) | Submit a testimony (optional prayerRequestId, isPublic); body: SubmitTestimonyDto |
| GET | /testimonies/mine?page=&limit= | Any (JwtAuthGuard) | Own testimony history |
| GET | /testimonies/public?page=&limit= | Any (JwtAuthGuard) | Opt-in public testimony feed |
| GET | /prayer-requests/team?status=&page=&limit= | JwtAuthGuard (Prayer-dept worker or Clergy) | Cross-member browse (mobile) |
| PATCH | /prayer-requests/team/:id/status | JwtAuthGuard (Prayer-dept worker or Clergy) | Update a request’s status (mobile) |
| GET | /prayer-requests/admin?status=&page=&limit= | AdminGuard (PRAYER_READ) | Cross-member browse (admin portal) |
| PATCH | /prayer-requests/admin/:id/status | AdminGuard (PRAYER_WRITE) | Update a request’s status (admin portal) |
| GET | /testimonies/admin?page=&limit= | AdminGuard (PRAYER_READ) | Full testimony browse (not just public ones) |
| GET | /prayer-requests/team/pregnancy-cases?status=&page=&limit= | JwtAuthGuard (Prayer-dept worker or Clergy) | Cross-member pregnancy prayer case browse (mobile) |
| POST | /prayer-requests/team/pregnancy-cases | JwtAuthGuard (Prayer-dept worker or Clergy) | Create a pregnancy prayer case (mobile) |
| POST | /prayer-requests/team/pregnancy-cases/:id/visit | JwtAuthGuard (Prayer-dept worker or Clergy) | Log a prayer visit (mobile) |
| PATCH | /prayer-requests/team/pregnancy-cases/:id/status | JwtAuthGuard (Prayer-dept worker or Clergy) | Update case status (mobile) |
| GET | /prayer-requests/team/pregnancy-cases/:id/visits | JwtAuthGuard (Prayer-dept worker or Clergy) | Full visit log for a case, newest first (mobile) |
| GET | /prayer-requests/admin/pregnancy-cases?status=&page=&limit= | AdminGuard (PRAYER_READ) | Cross-member pregnancy prayer case browse (admin portal) |
| PATCH | /prayer-requests/admin/pregnancy-cases/:id/status | AdminGuard (PRAYER_WRITE) | Update case status (admin portal) |
| GET | /prayer-requests/admin/pregnancy-cases/:id/visits | AdminGuard (PRAYER_READ) | Full visit log for a case, newest first (admin portal) |
| POST | /leave | WORKER | Request leave |
| PATCH | /leave/:id/action | AdminGuard (LEAVE_WRITE) | Approve or reject leave |
| DELETE | /leave/:id | WORKER | Delete own pending leave |
| GET | /leave/my-history?page=&limit=&status= | WORKER | Own leave history (paginated) |
| GET | /leave/history | AdminGuard (LEAVE_READ) | All leave requests |
| GET | /leave/department?page=&limit=&status= | WORKER | Department leave requests (lead only, paginated) |
| POST | /classes | AdminGuard (CLASSES_WRITE) | Create class (body: classTypeId, not type; optional minAttendancePercent, requireAllAssignments, openForRequests, capacity). Returns the full class (type, facilitators, materials) |
| PATCH | /classes/:id | AdminGuard (CLASSES_WRITE) | Update class; returns the full class (type, facilitators, materials) |
| DELETE | /classes/:id | AdminGuard (CLASSES_WRITE) | Delete class |
| GET | /classes?classTypeId= | Any | List classes (filterable by classTypeId) |
| GET | /classes/:id | Any | Get class |
| POST | /classes/enroll | AdminGuard (CLASSES_WRITE) | Enrol member in class |
| PATCH | /classes/enrollments/:id/status | AdminGuard (CLASSES_WRITE) | Update enrolment status |
| PATCH | /classes/enrollments/:id/certificate | AdminGuard (CLASSES_WRITE) | Issue a certificate for a COMPLETED enrolment (body: optional certificateNumber; next CERT-YYYY-NNNN when omitted) |
| GET | /classes/enrollments/:id/promotion-candidate | AdminGuard (CLASSES_READ) | Check level-promotion eligibility + open classes of the next type |
| POST | /classes/enrollments/:id/promote | AdminGuard (CLASSES_WRITE) | Promote a COMPLETED enrolment into the next class type (body: targetClassId) |
| GET | /classes/my/enrollments | Any | Own enrolments |
| GET | /classes/:id/enrollments | AdminGuard (CLASSES_READ) | All enrolments for a class |
| POST | /classes/types | AdminGuard (CLASSES_WRITE) | Create class type |
| PATCH | /classes/types/:id | AdminGuard (CLASSES_WRITE) | Update class type (name/description/isActive/nextClassTypeId) |
| DELETE | /classes/types/:id | AdminGuard (CLASSES_WRITE) | Delete class type (blocked if any class still references it) |
| GET | /classes/types | Any | List all class types (unpaginated, cached) — member-readable so the mobile app can show current types |
| GET | /classes/types/:id | Any | Get class type |
| POST | /announcements | AdminGuard (ANNOUNCEMENTS_WRITE) | Create announcement; optional sendViaSms (requires SMS_SEND) + smsBody (required if sendViaSms=true) |
| POST | /announcements/sms-broadcast | AdminGuard (SMS_SEND) | Send an SMS to an audience without creating an announcement; body { audience, departmentId?/targetMemberId?/groupId?, message } — returns { sentCount, failedCount?, failures? } |
| PATCH | /announcements/:id | AdminGuard (ANNOUNCEMENTS_WRITE) | Update announcement; same sendViaSms/smsBody rules as create — SMS only (re-)sent on the transition into sendViaSms=true |
| DELETE | /announcements/:id | AdminGuard (ANNOUNCEMENTS_WRITE) | Delete announcement |
| GET | /announcements/all?search=&audience=&page=&limit= | AdminGuard (ANNOUNCEMENTS_READ) | All announcements (paginated); optional search filters by title (case-insensitive); optional audience filters by value (ALL/WORKERS_ONLY/MEMBERS_ONLY/DEPARTMENT/INDIVIDUAL) |
| GET | /announcements/feed | Any | My filtered feed |
| GET | /announcements/:id | Any | Get announcement |
| POST | /announcements/:id/react | Any | React with an emoji (upserts — one reaction per member per announcement) |
| DELETE | /announcements/:id/react | Any | Remove own reaction |
| GET | /announcements/:id/reactions | Any | { summary: {emoji,count}[], myReaction } — myReaction reflects the calling member |
| GET | /admin/sms/balance | AdminGuard (SMS_READ) | Returns { balance, currency } from the SMS provider |
| POST | /admin/sms/segment-count | AdminGuard (SMS_READ) | Body { message } — returns { segments, encoding, characterCount } |
| GET | /admin/sms/logs | AdminGuard (SMS_READ) | Live passthrough to the provider’s message history — not paginated/filtered server-side |
| GET | /birthday/today | Any (JwtAuthGuard) | List active members with a birthday today (birthDay + birthMonth match current date) |
| GET | /birthday/upcoming | AdminGuard (MEMBERS_READ) | List active members with upcoming birthdays; ?days=N (default 7) sets the lookahead window, ordered by month/day |
| POST | /birthday/wishes/:recipientId | Any | Send a birthday wish (once per year per sender; rate-limited to WISH_DAILY_LIMIT/day) |
| GET | /birthday/wishes/me | Any | Read own birthday wishes (?year= filter optional) |
| GET | /birthday/wishes/:memberId | AdminGuard (MEMBERS_READ) | Read any member’s birthday wishes |
| GET | /dashboard/member | Any | Member dashboard |
| GET | /dashboard/worker | WORKER | Worker dashboard |
| GET | /dashboard/admin | AdminGuard (DASHBOARD_READ) | Admin dashboard |
| POST | /sunday-school/classes | WORKER (SS-dept or class teacher) | Create SS class |
| PATCH | /sunday-school/classes/:id | WORKER (SS-dept or class teacher) | Update SS class (incl. ageGroup, meetingDay, meetingTime, location, assistantIds) |
| DELETE | /sunday-school/classes/:id | AdminGuard (SUNDAY_SCHOOL_WRITE) | Delete SS class |
| GET | /sunday-school/classes | Any | List SS classes |
| GET | /sunday-school/classes/:id | Any | Get SS class by ID |
| POST | /sunday-school/classes/:id/members | WORKER (SS-dept or class teacher) | Assign member to class. This, bulk add and candidates return 403 when teachersCanAddMembers is off |
| POST | /sunday-school/classes/:id/members/bulk | WORKER (SS-dept or class teacher) | Bulk add members by id and/or email — same body, rules and response as the admin bulk add. Used by the member app’s “Add members” picker |
| GET | /sunday-school/classes/:id/candidates?search=&page=&limit= | WORKER (SS-dept or class teacher) | Members who can be added to the class — same response as the admin candidates endpoint (otherClasses, blocked) |
| DELETE | /sunday-school/classes/:id/members/:memberId | WORKER (SS-dept or class teacher) | Remove member from class |
| GET | /sunday-school/classes/:id/members | WORKER (SS-dept or class teacher) | List class members |
| POST | /sunday-school/sessions | WORKER (SS-dept or class teacher) | Create SS session |
| POST | /sunday-school/sessions/series | WORKER (SS-dept or class teacher) | Create a session every N weeks between two dates (skips existing dates; ≤60) |
| PATCH | /sunday-school/sessions/:id | WORKER (SS-dept or class teacher) | Edit a session’s date, notes or lesson link; attendance is kept |
| GET | /sunday-school/classes/:id/absentees?misses= | WORKER (SS-dept, class teacher or assistant) | Members who missed their last N sessions in a row (default 3) |
| PATCH | /sunday-school/sessions/:id/open | WORKER (SS-dept or class teacher) | Open self-mark window for N minutes (body: { closesInMinutes }); pushes “check-in open” to unmarked class members |
| PATCH | /sunday-school/sessions/:id/close | WORKER (SS-dept or class teacher) | Close self-mark window immediately |
| GET | /sunday-school/sessions/open | Any authenticated member | List sessions with an active self-mark window that the member is enrolled in |
| GET | /sunday-school/attendance/me | Any authenticated member | Paginated list of the member’s own Sunday School attendance history |
| POST | /sunday-school/sessions/:id/checkin | Any (self-mark; member must be enrolled; window must be open) | Self-mark attendance |
| POST | /sunday-school/sessions/:id/bulk-mark | WORKER (SS-dept or class teacher) | Bulk mark session attendance; 403 after the teacher marking window (teacherMarkingDays) |
| POST | /sunday-school/sessions/:id/checkin-first-timer | WORKER (SS-dept or class teacher) | Check in someone with no Member record — creates a real FirstTimer (+ follow-up task) and marks them PRESENT. 403 when teachersCanCheckInFirstTimers is off |
| GET | /sunday-school/settings | WORKER | { teachersCanAddMembers, teachersCanCheckInFirstTimers, oneClassPerMember } — the member app hides switched-off teacher options |
| GET | /sunday-school/sessions/:id/roster | WORKER (SS-dept or class teacher) | Get session attendance roster — also returns firstTimerCheckIns[], teacherMarkingOpen, teacherMarkingClosesOn |
| GET | /sunday-school/sessions?classId= | Any | List sessions for a class (paginated) |
| GET | /sunday-school/sessions/:id | Any | Get SS session by ID |
| DELETE | /sunday-school/sessions/:id | AdminGuard (SUNDAY_SCHOOL_WRITE) | Delete SS session |
| GET | /sunday-school/my-classes | Any authenticated member | Classes the caller is assigned to (with teacher and class details) |
| GET | /sunday-school/my-teaching | WORKER | Classes the caller teaches or assists, with membersCount |
| POST | /sunday-school/classes/:id/questions | Any (member must be enrolled in the class) | Ask a private question in a class |
| GET | /sunday-school/classes/:id/questions | WORKER (SS-dept or class teacher) | List questions asked in a class (paginated) |
| GET | /sunday-school/questions/me | Any authenticated member | Paginated list of the member’s own questions, across all classes |
| PATCH | /sunday-school/questions/:id/answer | WORKER (SS-dept or class teacher) | Answer a question (body: { answerText }) |
| GET | /sunday-school/questions | WORKER (SS-dept capability only, no class-teacher fallback) | Questions across every class, paginated |
| GET | /admin/sunday-school/classes | AdminGuard (SUNDAY_SCHOOL_READ) | List SS classes (paginated) |
| POST | /admin/sunday-school/classes | AdminGuard (SUNDAY_SCHOOL_WRITE) | Create SS class (no auth restriction on department or teacher) |
| PATCH | /admin/sunday-school/classes/:id | AdminGuard (SUNDAY_SCHOOL_WRITE) | Update SS class (incl. ageGroup, meetingDay, meetingTime, location, assistantIds) |
| DELETE | /admin/sunday-school/classes/:id | AdminGuard (SUNDAY_SCHOOL_WRITE) | Delete SS class |
| GET | /admin/sunday-school/classes/:id/members | AdminGuard (SUNDAY_SCHOOL_READ) | List members of an SS class (paginated) |
| POST | /admin/sunday-school/classes/:id/members | AdminGuard (SUNDAY_SCHOOL_WRITE) | Assign a member to an SS class |
| POST | /admin/sunday-school/classes/:id/members/bulk | AdminGuard (SUNDAY_SCHOOL_WRITE) | Add many members at once. Body { memberIds?: uuid[], emails?: string[] } (≤500 each, at least one). Members already in the class are skipped; with one-class-per-member on, members already in another class are skipped too. Returns { added, alreadyInClass, notFound, inAnotherClass: [{ memberId, className }] } (notFound = unknown ids/emails). The admin’s class panel uses it for “Choose from list” and “Paste emails” |
| GET | /admin/sunday-school/classes/:id/candidates?search=&page=&limit= | AdminGuard (SUNDAY_SCHOOL_READ) | Members who can be added to the class (not already in it, not INACTIVE); search matches first/last/full name or email; limit ≤100. Each row has otherClasses: string[] and blocked (true when one-class-per-member is on and they’re in another class). Response also carries oneClassPerMember |
| GET | /admin/sunday-school/settings | AdminGuard (SUNDAY_SCHOOL_READ) | { oneClassPerMember, teachersCanAddMembers, teachersCanCheckInFirstTimers, teacherMarkingDays, membersInSeveralClasses }. teacherMarkingDays is the church_settings row sunday_school:teacher_marking_days { days } (default 2, 0–30). Each switch is a church_settings row { enabled }: sunday_school:one_class_per_member (default false), sunday_school:teachers_can_add_members (default true), sunday_school:teachers_can_check_in_first_timers (default true); each cached 5 min |
| PUT | /admin/sunday-school/settings | AdminGuard (SUNDAY_SCHOOL_WRITE) | Body: any of { oneClassPerMember?, teachersCanAddMembers?, teachersCanCheckInFirstTimers?, teacherMarkingDays? } (booleans, plus teacherMarkingDays 0–30; only sent fields change). teachersCanAddMembers: false → teacher add/bulk add/candidates return 403 (admins only); teachersCanCheckInFirstTimers: false → teacher first-timer check-in returns 403 (admins use the admin route). oneClassPerMember on: adding a member who is already in another class (admin single/bulk add or worker assign) is refused with 400; existing extra memberships are left alone and counted in membersInSeveralClasses. Audited as SUNDAY_SCHOOL_SETTINGS_UPDATED |
| DELETE | /admin/sunday-school/classes/:id/members/:memberId | AdminGuard (SUNDAY_SCHOOL_WRITE) | Remove a member from an SS class |
| GET | /admin/sunday-school/sessions?classId= | AdminGuard (SUNDAY_SCHOOL_READ) | List sessions for a class (paginated; classId required UUID query param) |
| POST | /admin/sunday-school/sessions | AdminGuard (SUNDAY_SCHOOL_WRITE) | Create SS session |
| POST | /admin/sunday-school/sessions/series | AdminGuard (SUNDAY_SCHOOL_WRITE) | Create a session every N weeks between two dates (skips existing dates; ≤60) |
| PATCH | /admin/sunday-school/sessions/:id | AdminGuard (SUNDAY_SCHOOL_WRITE) | Edit a session’s date, notes or lesson link; attendance is kept |
| GET | /admin/sunday-school/reports/attendance?from&to&classId | AdminGuard (SUNDAY_SCHOOL_READ) | Attendance report: summary, per class, per session, per member (with classId) — see Sunday School Module |
| GET | /admin/sunday-school/reports/attendance/export?from&to&classId | AdminGuard (SUNDAY_SCHOOL_READ) | .xlsx download: Classes, Sessions, Members sheets |
| GET | /admin/sunday-school/reports/absentees?classId&misses | AdminGuard (SUNDAY_SCHOOL_READ) | Members who missed their last N sessions in a row, all classes or one |
| DELETE | /admin/sunday-school/sessions/:id | AdminGuard (SUNDAY_SCHOOL_WRITE) | Delete SS session |
| PATCH | /admin/sunday-school/sessions/:id/open | AdminGuard (SUNDAY_SCHOOL_WRITE) | Open self-mark window (body: { closesInMinutes }); pushes “check-in open” to unmarked class members |
| PATCH | /admin/sunday-school/sessions/:id/close | AdminGuard (SUNDAY_SCHOOL_WRITE) | Close self-mark window |
| GET | /admin/sunday-school/sessions/:id/roster | AdminGuard (SUNDAY_SCHOOL_READ) | Get session attendance roster |
| POST | /admin/sunday-school/sessions/:id/bulk-mark | AdminGuard (SUNDAY_SCHOOL_WRITE) | Bulk mark session attendance; returns { marked: number } |
| POST | /admin/sunday-school/sessions/:id/checkin-first-timer | AdminGuard (SUNDAY_SCHOOL_WRITE) | Admin first-timer check-in — same body/behaviour as the teacher route, FirstTimer recorded with the admin as creator; unaffected by teachersCanCheckInFirstTimers. Used by the admin “Mark attendance” window |
| GET | /admin/sunday-school/questions | AdminGuard (SUNDAY_SCHOOL_READ) | Questions across every class, paginated |
| GET | /admin/sunday-school/classes/:id/questions | AdminGuard (SUNDAY_SCHOOL_READ) | List questions asked in a class (paginated) |
| PATCH | /admin/sunday-school/questions/:id/answer | AdminGuard (SUNDAY_SCHOOL_WRITE) | Answer a question (body: { answerText }) |
| DELETE | /admin/sunday-school/questions/:id | AdminGuard (SUNDAY_SCHOOL_WRITE) | Delete a question (moderation) |
| POST | /children-church/age-groups | AdminGuard (CHILDREN_CHURCH_WRITE) | Create age group |
| PATCH | /children-church/age-groups/:id | AdminGuard (CHILDREN_CHURCH_WRITE) | Update age group |
| DELETE | /children-church/age-groups/:id | AdminGuard (CHILDREN_CHURCH_WRITE) | Delete age group |
| GET | /children-church/age-groups | Any | List age groups |
| POST | /children-church/age-groups/recompute | AdminGuard (CHILDREN_CHURCH_WRITE) | Batch reassign all children to correct age/class group |
| POST | /children-church/class-groups | AdminGuard (CHILDREN_CHURCH_WRITE) | Create class group |
| PATCH | /children-church/class-groups/:id | AdminGuard (CHILDREN_CHURCH_WRITE) | Update class group |
| DELETE | /children-church/class-groups/:id | AdminGuard (CHILDREN_CHURCH_WRITE) | Delete class group |
| GET | /children-church/class-groups?ageGroupId= | WORKER (CC-dept) | List class groups (filterable by age group) |
| POST | /children-church/children | WORKER (CC-dept) | Register child |
| PATCH | /children-church/children/:id | WORKER (CC-dept) | Update child profile |
| GET | /children-church/children/:id | WORKER (CC-dept) | Get child by ID |
| GET | /children-church/children/:id/checkin-history | WORKER (CC-dept) | Child check-in history (paginated) |
| GET | /children-church/children?name=&classGroupId=&page=&limit= | WORKER (CC-dept) | Search/list children |
| POST | /children-church/children/:id/guardians | WORKER (CC-dept) | Add guardian to child |
| GET | /children-church/children/:id/guardians | WORKER (CC-dept) | List child guardians |
| DELETE | /children-church/guardians/:id | WORKER (CC-dept) | Remove guardian |
| POST | /children-church/checkin | WORKER (CC-dept) | Check in a child |
| GET | /children-church/checkin/verify/:code | WORKER (CC-dept) | Verify pickup code |
| POST | /children-church/checkout | WORKER (CC-dept) | Check out a child |
| PATCH | /children-church/checkin/:id/flag | WORKER (CC-dept) | Flag a check-in record |
| GET | /children-church/checkin/active?classGroupId= | WORKER (CC-dept) | List active check-ins |
| GET | /children-church/admin/checkin/active?classGroupId= | AdminGuard (CHILDREN_CHURCH_READ) | Admin view — all active check-ins, optional class group filter |
| GET | /children-church/admin/checkin/history?page=&limit=&classGroupId=&status=&slotId= | AdminGuard (CHILDREN_CHURCH_READ) | Admin paginated check-in history; filters: classGroupId, status (CHECKED_IN/CHECKED_OUT/FLAGGED), slotId |
| GET | /children-church/checkin/slot/:slotId?page=&limit= | AdminGuard (CHILDREN_CHURCH_READ) | All check-ins for a service slot (paginated, default limit 20) |
| GET | /admin/tithes/records | AdminGuard (FINANCE_READ) | List all confirmed tithe records (paginated); filters: memberId, departmentId, fromMonth, toMonth, search |
| GET | /admin/tithes/records/download | AdminGuard (FINANCE_READ) | Download filtered tithe records as .xlsx; same query params as list endpoint, no pagination |
| GET | /admin/tithes/template | AdminGuard (FINANCE_READ) | Download the tithe upload Excel template (3-sheet workbook) |
| POST | /admin/tithes/upload | AdminGuard (FINANCE_WRITE) | Upload tithe payment Excel; validates headers, creates batch, dispatches Bull job |
| GET | /admin/tithes/batches?status=&page=&limit= | AdminGuard (FINANCE_READ) | List all upload batches (paginated); optional status filter (PENDING/PROCESSING/COMPLETED/FAILED) |
| GET | /admin/tithes/batches/:id | AdminGuard (FINANCE_READ) | Get batch by ID |
| POST | /admin/tithes/batches/:id/requeue | AdminGuard (FINANCE_WRITE) | Requeue a FAILED batch using stored row data; resets status to PENDING |
| GET | /admin/tithes/unmatched?status=&search=&page=&limit= | AdminGuard (FINANCE_READ) | List unmatched rows; status defaults to PENDING; search filters by rawEmail or reference (case-insensitive) |
| POST | /admin/tithes/unmatched/:id/match | AdminGuard (FINANCE_WRITE) | Manually match an unmatched row to a member; creates TitheRecord |
| POST | /admin/tithes/unmatched/:id/dismiss | AdminGuard (FINANCE_WRITE) | Mark an unmatched row as DISMISSED (intentionally ignored) |
| GET | /admin/tithes/disputes?status=&search=&page=&limit= | AdminGuard (FINANCE_READ) | List dispute records; status defaults to PENDING; search filters by member firstname, lastname, or email |
| PATCH | /admin/tithes/disputes/:id/approve | AdminGuard (FINANCE_WRITE) | Approve a tithe dispute (creates TitheRecord) |
| PATCH | /admin/tithes/disputes/:id/reject | AdminGuard (FINANCE_WRITE) | Reject a tithe dispute |
| GET | /tithes/me | Any (JwtAuthGuard) | Member’s own tithe records (paginated) |
| GET | /tithes/me/summary | Any (JwtAuthGuard) | The caller’s giving for one year by type (year query, default current year): { year, years, total, count, byType } |
| POST | /tithes/me/statement/send | Any (JwtAuthGuard) | Email a PDF Giving Statement to the caller’s registered email. Optional query: fromMonth (YYYY-MM), toMonth (YYYY-MM), givingOptionId (UUID). Selecting an option includes matching TitheRecords only; omitting it includes all giving, including confirmed pledge contributions |
| POST | /tithes/me/pledge-statement/send | Any (JwtAuthGuard) | Email a PDF statement of the caller’s confirmed pledge contributions only. Optional query fromMonth, toMonth (YYYY-MM), campaignId. Returns a message and recordCount; sends no email when nothing matches |
| POST | /tithes/proof | Any (JwtAuthGuard) | Submit tithe payment proof (multipart, field: file, max 2 MB); body: titheAccountId, amount, paymentDate, reference?, givingOptionId? (what this payment was for; omit for General Giving) |
| GET | /tithes/proof | Any (JwtAuthGuard) | List caller’s own tithe payment proofs (paginated) |
| GET | /admin/tithes/proofs?status=&search=&page=&limit= | AdminGuard (FINANCE_READ) | List all tithe payment proofs; optional status filter (PENDING/CONFIRMED/DECLINED); search filters by member firstname, lastname, or email |
| POST | /admin/tithes/proofs/:id/confirm | AdminGuard (FINANCE_WRITE) | Confirm a tithe payment proof; creates a TitheRecord (source MANUAL_PROOF) so it appears in the member’s giving history/statement, and notifies member by email |
| POST | /admin/tithes/proofs/:id/decline | AdminGuard (FINANCE_WRITE) | Decline a tithe payment proof (body: financeNote); notifies member by email |
| GET | /admin/finance/categories | AdminGuard (FINANCE_READ) | List finance categories |
| POST | /admin/finance/categories | AdminGuard (FINANCE_WRITE) | Create finance category |
| PATCH | /admin/finance/categories/:id | AdminGuard (FINANCE_WRITE) | Update finance category (name, description, or isActive) |
| DELETE | /admin/finance/categories/:id | AdminGuard (FINANCE_WRITE) | Delete a finance category; 400 if the category is referenced by any FinanceRequest (disable it via PATCH isActive: false instead) |
| GET | /admin/finance/requests | AdminGuard (FINANCE_READ) | List finance requests (paginated); filters: status (incl. AWAITING_PAYMENT, PAID), categoryId, memberId, departmentId, search; rows include computed isPaid |
| GET | /admin/finance/requests/download | AdminGuard (FINANCE_READ) | Download filtered finance requests as .xlsx; same query params as list endpoint, no pagination |
| GET | /admin/finance/requests/:id | AdminGuard (FINANCE_READ) | Get finance request by ID |
| PATCH | /admin/finance/requests/:id/approve | AdminGuard (FINANCE_WRITE) | Approve a pending finance request — 403 if the approver is the same member who raised the request |
| PATCH | /admin/finance/requests/:id/reject | AdminGuard (FINANCE_WRITE) | Reject a pending finance request (body: rejectionReason) |
| PATCH | /admin/finance/requests/:id/proof | AdminGuard (FINANCE_WRITE) | Attach payment proof to an approved request (multipart, field: file) |
| GET | /finance/categories | WORKER (RolesGuard) | List finance categories (visible to HOD for request creation); only isActive: true categories are returned |
| POST | /finance/requests | WORKER — HOD only | Raise a finance request for own department (multipart optional: attachment) |
| GET | /finance/requests | WORKER — HOD only | List own department’s finance requests (paginated); each row includes department and requestedBy relations |
| GET | /finance/requests/:id | WORKER — HOD only | Get a single request from own department (includes proofUrl once attached) |
| POST | /service-programme | AdminGuard + SERVICE_PROGRAMME_WRITE | Create a programme for one or more service slots in one call — body is { programmes: [{ serviceSlotId, slots? }], saveAsTemplate? } (programmes min 1). One ServiceProgramme per slot still (1:1 with ServiceSlot), but a multi-service Sunday (First/Second Service under one Event) can be programmed in a single request instead of one round trip per slot. Each entry’s slots (order-of-service items) is independent — sibling slots are not required to have matching items, or any items at all. 404 if any serviceSlotId doesn’t exist; 409 (naming the affected slots) if any already has a programme — the whole call is rejected, none are created. Each slots item is created the same way POST /service-programme/:id/slots would (member/backup resolution, assignment email, conflict-warning check), in array order starting at position 0. saveAsTemplate applies to every programme created in the call. Response is a single fully-loaded programme (same shape as GET /service-programme/:id) when programmes has one entry, or an array of them when it has multiple. Omitting an entry’s slots still creates that programme as an empty DRAFT, added to later. |
| GET | /service-programme | AdminGuard + SERVICE_PROGRAMME_READ | List all programmes paginated (query: page, limit). Each result includes structured event: { id, name, eventDate } and serviceSlotDetail: { id, name, startTime, endTime } (in addition to the flattened serviceSlotName string) so the admin UI can group programmes by their parent event instead of rendering every slot as an unrelated row. |
| GET | /service-programme/my-assignments | JwtAuthGuard | The calling member’s own upcoming slots (as primary or backup) across every DRAFT/LIVE programme, ordered by service start time. Each entry includes isBackup, so a member on standby can tell it apart from a confirmed slot. Excludes COMPLETED programmes and anything already in the past. Also includes sessionCode — null until the programme’s session goes LIVE, then the code needed to call GET /service-session/:sessionCode/my-status. Includes slots held by the caller’s department(s), with asDepartment: { id, name }. |
| GET | /service-programme/upcoming | JwtAuthGuard | The general order-of-service view — the soonest LIVE-or-still-upcoming-DRAFT programme, with every slot (not scoped to the caller), mapped to speakerName/backupSpeakerName strings only (never the raw Member row). Returns null rather than 404ing when nothing qualifies. Also includes sessionCode once LIVE. Must stay registered before :id below in the controller. |
| GET | /service-programme/templates | AdminGuard + SERVICE_PROGRAMME_READ | List all reusable programme templates ordered by name |
| DELETE | /service-programme/templates/:templateId | AdminGuard + SERVICE_PROGRAMME_WRITE | Delete a template |
| GET | /service-programme/:id | AdminGuard + SERVICE_PROGRAMME_READ | Get a single programme with all slots and member relations. Each slot includes flattened memberName/backupMemberName strings derived from the loaded member/backupMember relations, so the frontend never has to resolve the relation object itself. |
| PATCH | /service-programme/:id | AdminGuard + SERVICE_PROGRAMME_WRITE | Update programme metadata (saveAsTemplate flag). Existed with no frontend consumer until now — ProgrammeDetailPanel has a “Save as template when completed” toggle next to the status flow. |
| DELETE | /service-programme/:id | AdminGuard + SERVICE_PROGRAMME_WRITE | Delete a DRAFT programme — 400 if LIVE or COMPLETED |
| POST | /service-programme/:id/slots | AdminGuard + SERVICE_PROGRAMME_WRITE | Append a slot (appended at next position). If memberId is set and that member has an email, queues a service-slot-assigned notification email. Accepts departmentId/backupDepartmentId instead of a member/guest (400 if both); the department’s members get a push and the HOD the email. |
| PUT | /service-programme/:id/slots/reorder | AdminGuard + SERVICE_PROGRAMME_WRITE | Reorder all slots (body: { slots: [{ id }] } in desired order) — DRAFT programmes only; for LIVE sessions use PUT /service-session/:sessionCode/slots/reorder |
| PATCH | /service-programme/:id/slots/:slotId | AdminGuard + SERVICE_PROGRAMME_WRITE | Update a single slot — 400 if programme is not DRAFT. Queues a service-slot-assigned email only when memberId newly changes to a different member (no email on unrelated edits or on clearing the assignment). departmentId/backupDepartmentId switch the slot to/from a department (the person is cleared). |
| DELETE | /service-programme/:id/slots/:slotId | AdminGuard + SERVICE_PROGRAMME_WRITE | Remove a slot — 400 if programme is not DRAFT |
| POST | /service-programme/:id/apply-template/:templateId | AdminGuard + SERVICE_PROGRAMME_WRITE | Apply a template to a DRAFT programme (clears existing slots, copies template structure) |
| GET | /service-programme/event/:eventId/pdf | AdminGuard + SERVICE_PROGRAMME_READ | Download the full event programme as a PDF (application/pdf). Covers every service slot in the event ordered by start time. Each service shows its programme slots (type, topic, speaker, backup, minutes) or a “no programme” notice if not yet created. Filename derived from event name. |
| GET | /service-programme/:id/pdf | AdminGuard + SERVICE_PROGRAMME_READ | Download a single programme as a PDF (application/pdf). Includes slot name, event date/time, all slots with type, topic, speaker, backup, and allocated minutes. |
| GET | /service-programme/:id/sessions | AdminGuard + SERVICE_PROGRAMME_READ | Paginated list of historical sessions for a programme (query: page, limit). Existed with no frontend consumer until now — ProgrammeDetailPanel has a collapsible “Session History” section (usually 0–1 entries under current business rules, since a programme can’t be restarted once it leaves DRAFT; the endpoint exists for the audit trail regardless). |
| POST | /service-session/programme/:programmeId/start | JwtAuthGuard (+ assertCanControlSession) | Start a session for a DRAFT programme; returns session with sessionCode and generates a Redis shareToken |
| POST | /service-session/event/:eventId/start | JwtAuthGuard (+ assertCanControlSession) | Starts only the next DRAFT programme in the event (earliest serviceSlot.startTime); returns a single session. 409 if a session for this event is already LIVE — end it first. 404 if the event has no service slots; 400 if no programme is startable (all already started/completed, or none have slots yet). Call again after ending the current session to advance to the next slot. |
| POST | /service-session/:sessionCode/advance | JwtAuthGuard (+ assertCanControlSession) | Advance to next slot; returns updated Redis anchor |
| POST | /service-session/:sessionCode/rewind | JwtAuthGuard (+ assertCanControlSession) | Go back to previous slot — 400 if already at first slot |
| POST | /service-session/:sessionCode/pause | JwtAuthGuard (+ assertCanControlSession) | Pause session (body: reason); creates ServicePauseEntry |
| POST | /service-session/:sessionCode/resume | JwtAuthGuard (+ assertCanControlSession) | Resume paused session; adjusts slotBaseSeconds to exclude pause duration |
| POST | /service-session/:sessionCode/adjust-time | JwtAuthGuard (+ assertCanControlSession) | Add/subtract seconds from the running slot’s remaining time (body: { deltaSeconds }, -3600…3600) |
| PUT | /service-session/:sessionCode/slots/reorder | JwtAuthGuard (+ assertCanControlSession) | Reorder the not-yet-started (PENDING) tail of ServiceSessionSlot rows for a LIVE session (body: { slots: [{ id }] }) — distinct from the DRAFT-only /service-programme/:id/slots/reorder |
| POST | /service-session/:sessionCode/slots/:position/override | RolesGuard (WORKER) + Admin dept | Runtime override for a slot (speakerName, topic, allocatedMinutes, memberId) |
| POST | /service-session/:sessionCode/end | JwtAuthGuard (+ assertCanControlSession) | End session; marks remaining slots SKIPPED; auto-saves template if saveAsTemplate |
| GET | /service-session/:sessionCode/share-links | JwtAuthGuard (+ assertCanControlSession) | Returns { sessionCode, shareToken } for building the public Presentation/Programme Manager links. Self-healing: if the session’s anchor is live but no shareToken was ever written (a race with the fire-and-forget set() in start(), a transient Redis hiccup, or a session that’s been live since before this field existed), a new token is generated and persisted on the fly instead of 404ing forever |
| POST | /service-session/:sessionCode/rotate-share-token | JwtAuthGuard (+ assertCanControlSession) | Regenerates the Redis-stored shareToken, invalidating any previously shared Programme Manager link without ending the session |
| POST | /service-session/:sessionCode/access-grants | JwtAuthGuard (+ assertCanControlSession) | Generate a named, individually-revocable PM-link credential (body: { name }); returns { id, name, pin } — the plaintext 6-digit PIN is shown exactly once and never retrievable again |
| GET | /service-session/:sessionCode/access-grants | JwtAuthGuard (+ assertCanControlSession) | List access grants for the session ({ id, name, createdAt, revokedAt, lastUsedAt }[], no PIN/hash exposed) |
| POST | /service-session/:sessionCode/access-grants/:grantId/revoke | JwtAuthGuard (+ assertCanControlSession) | Revoke a named grant; takes effect on that person’s next pm/* action without touching anyone else’s access or the shared link |
| POST | /service-session/:sessionCode/pm/access | Public + ShareTokenGuard (?token=) | Sign in to the Programme Manager link with { name, pin }; returns { grantToken, name } on success — this is the identity step itself, so it’s the one pm/* route that isn’t also gated by NamedAccessGuard |
| POST | /service-session/:sessionCode/pm/advance | Public + ShareTokenGuard (?token=) + NamedAccessGuard (?grantToken=) | Same as /advance, callable from the public Programme Manager link |
| POST | /service-session/:sessionCode/pm/rewind | Public + ShareTokenGuard (?token=) + NamedAccessGuard (?grantToken=) | Same as /rewind, callable from the public Programme Manager link |
| POST | /service-session/:sessionCode/pm/pause | Public + ShareTokenGuard (?token=) + NamedAccessGuard (?grantToken=) | Same as /pause, callable from the public Programme Manager link |
| POST | /service-session/:sessionCode/pm/resume | Public + ShareTokenGuard (?token=) + NamedAccessGuard (?grantToken=) | Same as /resume, callable from the public Programme Manager link |
| POST | /service-session/:sessionCode/pm/adjust-time | Public + ShareTokenGuard (?token=) + NamedAccessGuard (?grantToken=) | Same as /adjust-time, callable from the public Programme Manager link |
| PUT | /service-session/:sessionCode/pm/slots/reorder | Public + ShareTokenGuard (?token=) + NamedAccessGuard (?grantToken=) | Same as /slots/reorder, callable from the public Programme Manager link |
| POST | /service-session/:sessionCode/pm/slots/:position/override | Public + ShareTokenGuard (?token=) + NamedAccessGuard (?grantToken=) | Same as /slots/:position/override, callable from the public Programme Manager link — lets the PM rename a topic or swap the minister/speaker mid-service, not just admins |
| POST | /service-session/:sessionCode/pm/end | Public + ShareTokenGuard (?token=) + NamedAccessGuard (?grantToken=) | Same as /end, callable from the public Programme Manager link (session end is included in the public link’s scope by product decision) |
| GET | /service-session/active | AdminGuard + SERVICE_PROGRAMME_READ | Returns { sessionCode, serviceSlotName, startedAt }[] for every currently LIVE session — powers the global “Live” indicator shown in the admin top bar on every page |
| GET | /service-session/analytics | AdminGuard + SERVICE_PROGRAMME_READ | Aggregate analytics across COMPLETED sessions (query: from, to, serviceSlotName — matches either the sub-service’s own name or its parent event’s name; memberId — restricts to sessions this member appeared in, as originally assigned or as whoever stepped in; slotType — restricts the type/speaker breakdown to one ServiceSlotTypeEnum value); overrun stats, avg times, top speakers |
| GET | /service-session/my-history?page=&limit= | JwtAuthGuard | The calling member’s own COMPLETED-session slot history (query: page, limit; default 1/10). Returns { totalSlots, totalActualSeconds, bySlotType: [{type, count, totalActualSeconds}], entries: [{eventName, serviceSlotName, sessionDate, type, topic, allocatedMinutes, actualSeconds}], page, limit, totalCount, totalPages }. Summary/bySlotType are computed over the caller’s full history, not just the current page. Credits only the effective speaker of a slot (overriddenMember?.id ?? programmeSlot.member?.id) — a listed backup who never actually went on gets no credit, matching the same rule getAnalytics’s memberId filter already uses. Powers the member-facing app’s “Service History” page. Includes the caller’s department slots (asDepartment per entry) and a byDepartment team-performance rollup. |
| GET | /service-session/:sessionCode/state | Public (@Public()) | Get live session state — anchor from Redis + programme data + effectiveSlots (see below); used by presentation, audience, and Programme Manager views |
| GET | /service-session/:sessionCode/slots/:position | Public (@Public()) | Single slot state for speaker view — programmeSlot data, overrides, and current anchor |
| GET | /service-session/:sessionCode/my-status | JwtAuthGuard | The calling member’s personal view of a LIVE session — role (PRIMARY/BACKUP), position, whether it’s currently their turn (isMyTurnNow), whether they’ve already gone (hasPassed), an estimatedSecondsUntilMyTurn (remaining time on the current slot plus the allocated time of every slot in between, null once it’s their turn or already passed), and the full runningOrder. 404 if the caller has no primary or backup slot in the session. Powers the member-facing app’s real-time “my slot” view (/my-assignment/:sessionCode), polled every 8s. Also matches through the caller’s department (asDepartment). |
| GET | /service-session/:sessionCode/report | AdminGuard + SERVICE_PROGRAMME_READ | Formatted session report: duration, completion rate, per-slot overrun, pause log |
| GET | /service-session/:sessionCode/report/pdf | AdminGuard + SERVICE_PROGRAMME_READ | Download session report as a PDF file — same data as JSON report, formatted for printing and sharing |
| GET | /service-session/:sessionCode/pm/report/pdf | Public + ShareTokenGuard (?token=) + NamedAccessGuard (?grantToken=) | Same PDF as above, callable from the public Programme Manager link — surfaced on the manage page’s “Session Ended” screen |
| GET | /service-session/:sessionCode/action-log | JwtAuthGuard (+ assertCanControlSession) | Returns the 10 most recent ServiceActionEntry rows (newest first) as JSON — powers the “Recent Activity” feed on the Live Session Dashboard. Same access tier as the control actions (not admin-only), since it’s operational context, not a compliance artifact. |
| GET | /service-session/:sessionCode/action-log/csv | AdminGuard + SERVICE_PROGRAMME_READ | Download the full ServiceActionEntry audit trail for a session as CSV (Timestamp, Actor Role, Actor, Action, Detail) — admin-only compliance export, distinct from the JSON feed above |
| GET | /service-session/event/:eventId/report/pdf | AdminGuard + SERVICE_PROGRAMME_READ | Download a full-event PDF covering all service slots in one document. Requires all sessions to be COMPLETED; returns 400 if any are still live and 404 if none exist. Includes variance summary table, per-slot allocated vs actual, slot variance (sum of individual slot overruns), and an ACCENT time-summary band per section. |
| GET | /service-session/event/:eventId/report/summary-pdf | AdminGuard + SERVICE_PROGRAMME_READ | Download a shareable one-page event summary PDF (admin access). Does NOT require sessions to be COMPLETED — works at any point after at least one session has started. Contains 4 stat cards (Speakers Done, Total Allocated, Total Actual, Overall Variance) and a single flat table across all services: # | Speaker | Topic/Slot | Allocated | Actual | Variance | Status. Times in MM:SS. Status labels: Over Time (red), Under Time/On Time (green), Not Used/Pending (muted). Returns 404 if no sessions exist. Now wired to a “Summary” button in the Programmes list’s per-event header, shown whenever at least one sub-service is no longer DRAFT (matching this route’s actual requirement, looser than the “Session Report” button’s all-COMPLETED gate). |
| GET | /service-session/event/:eventId/summary-pdf | JwtAuthGuard + WORKER + Admin dept (primary or secondary) | Identical PDF to the admin route above, but accessible by workers in the Admin department (primary or secondary). Enforces assertIsAdminDeptWorker — returns 403 if the authenticated worker is not in the Admin department (no SERVICE_PROGRAMME_WRITE fallback; this check is intentionally separate from assertCanControlSession used by session control). Designed for mobile use: admin-dept workers can download and share the summary immediately after service ends. |
| POST | /service-headcount | AdminGuard + HEADCOUNT_WRITE | Record physical attendance headcount for a service slot (body: serviceSlotId, maleAdults, femaleAdults, teenagers, children, mobileChurch, customGroups?, notes?); upsert — recording again for the same slot edits the existing row. This is the only way to correct a record — the separate PATCH endpoint was removed (see ServiceHeadcount Module notes above). |
| GET | /service-headcount | AdminGuard + HEADCOUNT_READ | List headcount records (query: page, limit, serviceSlotId, from, to); each record includes computed total |
| GET | /service-headcount/trends | AdminGuard + HEADCOUNT_READ | Aggregated attendance trends bucketed by period (query: period=weekly|monthly|quarterly, from, to, serviceSlotName); returns grouped data per slot per bucket |
| GET | /service-headcount/event/:eventId/summary | AdminGuard + HEADCOUNT_READ | Every sub-service under the event with its headcount (or null) plus the aggregate total across recorded sub-services |
| GET | /service-headcount/:id | AdminGuard + HEADCOUNT_READ | Get a single headcount record by ID (includes computed total). Existed with no frontend consumer until now — the Records tab has a “View details” (eye icon) action per row, showing notes/customGroups/recordedBy (none of which the flat table has room for). |
| POST | /service-headcount/export-email | AdminGuard + HEADCOUNT_READ | Email the currently-filtered headcount rows as an .xlsx attachment (body: recipientEmail?, serviceSlotId?, from?, to?). recipientEmail defaults to the requesting admin’s own email. One-off only — not a recurring/scheduled report. Logs REPORT_EXPORTED. |
| GET | /admin/settings | AdminGuard (any admin) | List all known modules with their current enabled status, displayName, and required flag (absent row = enabled by default) |
| GET | /admin/settings/:key | AdminGuard (any admin) | Get one module setting by key (e.g. incident_report, asset_management). Returns required flag. |
| PATCH | /admin/settings/:key | AdminGuard (ADMIN_WRITE) | Enable/disable a module and/or set a displayName override — body: { enabled?: boolean, displayName?: string }. Returns 400 if disabling a required module. Merges rather than overwrites — omitting displayName preserves any previously-set label. Upserts the row, invalidates cache, and writes CHURCH_SETTING_UPDATED audit log. |
| GET | /modules/state | JwtAuthGuard (any authenticated role) | Shared read endpoint: { key, enabled, displayName }[] for every known module. Single source of truth consumed by both frontends for nav/tile visibility and permission-group visibility — see Church Settings Module. |
| POST | /incidents | JwtAuthGuard + Module: incident_report | Submit a new incident report. multipart/form-data. Rate-limited to INCIDENT_DAILY_REPORT_LIMIT (default 2) per member per 24 h. Fields: title, description, location?, isAnonymous? (default false). File field: images (up to 5 image files, max 5 MB each — uploaded to Cloudinary; incident-images folder). Notifies admins with INCIDENT_REPORT_WRITE permission by email. |
| GET | /incidents?page=&limit= | JwtAuthGuard + Module: incident_report | Returns only the current member’s own reports. Members cannot see reports submitted by others. |
| GET | /incidents/:id | JwtAuthGuard + Module: incident_report | Returns a single report only if it was submitted by the current member. Returns 404 otherwise. |
| GET | /admin/incidents?page=&limit=&status=&dateFrom=&dateTo= | AdminGuard (INCIDENT_REPORT_READ) | Paginated list of all incidents. Optional filters: status (OPEN/IN_PROGRESS/RESOLVED), dateFrom and dateTo (ISO date strings, inclusive). Reporter masked to null for anonymous reports. |
| GET | /admin/incidents/:id | AdminGuard (INCIDENT_REPORT_READ) | Get a single incident report with full details. |
| PATCH | /admin/incidents/:id/status | AdminGuard (INCIDENT_REPORT_WRITE) | Update incident status (OPEN → IN_PROGRESS → RESOLVED) and optionally set adminNotes. Sets resolvedAt automatically when status is RESOLVED. |
| GET | /prayer/admin/programs?name= | AdminGuard (PRAYER_READ) | List all prayer programs; optional name param does a case-insensitive partial match |
| POST | /prayer/admin/programs | AdminGuard (PRAYER_WRITE) | Create a prayer program; body: name, audience (WORKERS|MEMBERS|ALL), description?, selectionWindowDays? |
| PATCH | /prayer/admin/programs/:id | AdminGuard (PRAYER_WRITE) | Update a prayer program (any field including isActive) |
| DELETE | /prayer/admin/programs/:id | AdminGuard (PRAYER_WRITE) | Deactivate a prayer program (sets isActive = false) |
| POST | /prayer/admin/programs/:id/clone | AdminGuard (PRAYER_WRITE) | Clone a program: copies all day configs and rules into a new program; body: name, description?, audience?, selectionWindowDays?, includeFixedAssignments? |
| GET | /prayer/admin/config | AdminGuard (PRAYER_READ) | Get the active schedule config (selectionWindowDays) |
| PATCH | /prayer/admin/config | AdminGuard (PRAYER_WRITE) | Upsert the active schedule config |
| GET | /prayer/admin/day-configs?programId= | AdminGuard (PRAYER_READ) | List prayer day configs for a program, ordered by dayOfWeek |
| POST | /prayer/admin/day-configs?programId= | AdminGuard (PRAYER_WRITE) | Create a prayer day config for a program (one active config per day per program) |
| PATCH | /prayer/admin/day-configs/:id | AdminGuard (PRAYER_WRITE) | Update a prayer day config (mode, startTime, endTime, maxCapacity, isActive) |
| GET | /prayer/admin/rules?programId= | AdminGuard (PRAYER_READ) | List schedule rules for a program |
| POST | /prayer/admin/rules?programId= | AdminGuard (PRAYER_WRITE) | Create a schedule rule for a program |
| PATCH | /prayer/admin/rules/:id | AdminGuard (PRAYER_WRITE) | Update a schedule rule (value, isActive, etc.) |
| GET | /prayer/admin/fixed-assignments?programId= | AdminGuard (PRAYER_READ) | List active fixed assignments for a program with worker and day config relations |
| POST | /prayer/admin/fixed-assignments?programId= | AdminGuard (PRAYER_WRITE) | Create a fixed assignment; body: workerProfileId, dayConfigId |
| DELETE | /prayer/admin/fixed-assignments/:id | AdminGuard (PRAYER_WRITE) | Soft-deactivate a fixed assignment |
| POST | /prayer/admin/meetings/generate?programId= | AdminGuard (PRAYER_WRITE) | Generate all meetings for a month for a program; auto-applies fixed assignments; 409 if meetings already exist |
| POST | /prayer/admin/meetings/open-selection?programId= | AdminGuard (PRAYER_WRITE) | Open self-selection window for all PENDING meetings in a month for a program |
| POST | /prayer/admin/meetings/close-selection?programId= | AdminGuard (PRAYER_WRITE) | Close self-selection window for all OPEN meetings in a month for a program |
| POST | /prayer/admin/roster/auto-assign?programId=&month=&year= | AdminGuard (PRAYER_WRITE) | Auto-assign workers to a program’s meetings (clears AUTO_ASSIGNED first for idempotency); returns { assigned, unassignable } |
| POST | /prayer/admin/roster/manual-assign?programId= | AdminGuard (PRAYER_WRITE) | Manually assign a worker or member to a meeting; body: meetingId, workerProfileId? | memberId? |
| DELETE | /prayer/admin/roster/entries/:id | AdminGuard (PRAYER_WRITE) | Remove a SCHEDULED non-FIXED roster entry and decrement meeting capacity |
| GET | /prayer/admin/roster/validate?programId=&month=&year= | AdminGuard (PRAYER_READ) | Validate roster completeness; returns { valid, issues[] } with per-worker frequency and per-meeting leader checks |
| GET | /prayer/admin/roster/:month/:year?programId= | AdminGuard (PRAYER_READ) | Get full monthly roster for a program with all meetings, day configs, and roster entries |
| PATCH | /prayer/admin/roster/entries/:id/reschedule | AdminGuard (PRAYER_WRITE) | Soft-reschedule: marks old entry RESCHEDULED, creates new entry on target meeting with rescheduledFrom FK; body: { newMeetingId } |
| GET | /prayer/programs?name= | WORKER | List active prayer programs scoped to the caller (audience = WORKERS or ALL); optional name param does a case-insensitive partial match; used to obtain a programId before calling meeting/roster endpoints |
| GET | /prayer/available?programId=&month=&year= | WORKER | List open prayer meetings for a program with remaining capacity for the given month |
| GET | /prayer/my-roster?programId=&month=&year= | WORKER | Authenticated worker’s own roster entries for a program in the given month |
| GET | /prayer/my-status?programId=&month=&year= | WORKER | Returns { required, selected, canSubmit, entries } — shows progress toward frequency quota for a program |
| POST | /prayer/select?programId= | WORKER | Self-select a prayer slot; body: { meetingId }; enforced with pessimistic DB lock to prevent overbooking |
| POST | /facility-rental/admin/facilities | AdminGuard (FACILITY_RENTAL_WRITE) | Create a rental facility; body: name, basePrice, description?, capacity? |
| GET | /facility-rental/admin/facilities | AdminGuard (FACILITY_RENTAL_READ) | List all facilities |
| PATCH | /facility-rental/admin/facilities/:id | AdminGuard (FACILITY_RENTAL_WRITE) | Update facility (any field including isActive) |
| POST | /facility-rental/admin/pricing-tiers | AdminGuard (FACILITY_RENTAL_WRITE) | Upsert a pricing tier for a member category; body: memberCategory, discountType, discountValue |
| GET | /facility-rental/admin/pricing-tiers | AdminGuard (FACILITY_RENTAL_READ) | List all pricing tiers |
| DELETE | /facility-rental/admin/pricing-tiers/:id | AdminGuard (FACILITY_RENTAL_WRITE) | Remove a pricing tier |
| POST | /facility-rental/admin/addons | AdminGuard (FACILITY_RENTAL_WRITE) | Create add-on; body: name, price, cautionAmount?, description?, assetId? |
| GET | /facility-rental/admin/addons | AdminGuard (FACILITY_RENTAL_READ) | List active add-ons (with linked asset) |
| PATCH | /facility-rental/admin/addons/:id | AdminGuard (FACILITY_RENTAL_WRITE) | Update add-on |
| POST | /facility-rental/admin/calendar-blocks | AdminGuard (FACILITY_RENTAL_WRITE) | Create admin blackout block; body: facilityId, startDateTime, endDateTime, reason? |
| GET | /facility-rental/admin/calendar-blocks?facilityId= | AdminGuard (FACILITY_RENTAL_READ) | List blackout blocks for a facility |
| DELETE | /facility-rental/admin/calendar-blocks/:id | AdminGuard (FACILITY_RENTAL_WRITE) | Remove a calendar block |
| GET | /facility-rental/admin/bookings?status= | AdminGuard (FACILITY_RENTAL_READ) | List all bookings, optionally filtered by status |
| GET | /facility-rental/admin/bookings/:id | AdminGuard (FACILITY_RENTAL_READ) | Get single booking with addons and payments |
| PATCH | /facility-rental/admin/bookings/:id/confirm | AdminGuard (FACILITY_RENTAL_WRITE) | Confirm a pending booking; optional body: notes |
| PATCH | /facility-rental/admin/bookings/:id/reject | AdminGuard (FACILITY_RENTAL_WRITE) | Reject a pending booking; body: rejectionReason |
| PATCH | /facility-rental/admin/bookings/:id/discount | AdminGuard (FACILITY_RENTAL_WRITE) | Apply override discount; body: overrideDiscountType, overrideDiscountValue, overrideDiscountNote?; recalculates serviceFee and updates SERVICE_FEE payment record |
| DELETE | /facility-rental/admin/bookings/:id/discount | AdminGuard (FACILITY_RENTAL_WRITE) | Remove override discount; reverts to tier-based pricing |
| PATCH | /facility-rental/admin/payments/:id/paid | AdminGuard (FACILITY_RENTAL_WRITE) | Mark a payment as paid; optional body: reference, proofUrl |
| PATCH | /facility-rental/admin/payments/:id/refund | AdminGuard (FACILITY_RENTAL_WRITE) | Mark a caution payment as refunded (must be PAID first) |
| GET | /facility-rental/facilities | JwtAuthGuard | List active facilities (member-facing) |
| GET | /facility-rental/addons | JwtAuthGuard | List active add-ons with linked asset (member-facing) |
| GET | /facility-rental/facilities/:id/availability?from=&to= | JwtAuthGuard | Returns blocked time ranges (bookings + admin blocks) for the facility within a date window |
| POST | /facility-rental/bookings | JwtAuthGuard | Create booking; body: facilityId, startDateTime, endDateTime, purpose?, addons?: [{addonId, quantity}]; overlap-checked; price auto-computed from tier |
| GET | /facility-rental/bookings | JwtAuthGuard | Authenticated member’s own bookings |
| GET | /facility-rental/bookings/:id | JwtAuthGuard | Get own booking detail (returns 404 if belongs to another member) |
| PATCH | /facility-rental/bookings/:id/cancel | JwtAuthGuard | Cancel own booking (only PENDING or CONFIRMED) |
| GET | /admin/notification-templates/push | AdminGuard (admin:read), any plan | Every push type with default and current wording, placeholders and customized, plus customizationAvailable for the church’s plan |
| PUT | /admin/notification-templates/push/:key | AdminGuard (admin:write) + plan notification_customization | Save the church’s title/message for one push type. Body { title, body } |
| DELETE | /admin/notification-templates/push/:key | AdminGuard (admin:write) + plan notification_customization | Reset one push type to the default wording |
| POST | /admin/notification-templates/push/:key/test | AdminGuard (admin:write) + plan notification_customization | Send the draft (or saved) wording to the admin’s own device, filled with the admin’s own recipient details and sample values for the rest; { sent } or { sent: false, reason: 'NO_DEVICE' } |
| GET | /admin/notification-templates/email | AdminGuard (admin:read), any plan | Every customizable email (all member/worker emails) with defaults, current wording, placeholders, lockedNote, customized, plus customizationAvailable |
| POST | /admin/notification-templates/email/:key/preview | AdminGuard (admin:read), any plan | { subject, html } of the email with sample details and the church’s branding; body is an optional unsaved draft |
| PUT | /admin/notification-templates/email/:key | AdminGuard (admin:write) + plan notification_customization | Save wording { subject, heading, message, closing, signoff, signature } |
| DELETE | /admin/notification-templates/email/:key | AdminGuard (admin:write) + plan notification_customization | Reset one email to the default wording |
| POST | /admin/notification-templates/email/:key/test | AdminGuard (admin:write) + plan notification_customization | Send the draft (or saved) email to the admin’s own address, filled with the admin’s own recipient details and sample values for the rest, subject prefixed [Test] |
| GET | /admin/notification-templates/push/:key/history | AdminGuard (admin:read), any plan | Newest-first change history (up to 20) for one push type: [{ id, action, content: { title, body }, changedBy, createdAt }] |
| POST | /admin/notification-templates/push/:key/history/:versionId/restore | AdminGuard (admin:write) + plan notification_customization | Re-save an earlier version’s wording (re-validated, recorded as RESTORED) |
| GET | /admin/notification-templates/email/:key/history | AdminGuard (admin:read), any plan | Newest-first change history (up to 20) for one email, content holding all six wording fields |
| POST | /admin/notification-templates/email/:key/history/:versionId/restore | AdminGuard (admin:write) + plan notification_customization | Re-save an earlier version’s email wording (re-validated, recorded as RESTORED) |
| GET | /notifications/vapid-public-key | JwtAuthGuard | { publicKey } — the server’s VAPID public key; clients must use it as applicationServerKey when subscribing |
| POST | /notifications/subscribe | JwtAuthGuard | Register a Web Push subscription. Called once after first device registration (deviceId transitions from null). Also called after re-registering on a new device following an admin purge or OTP device reset. Body: endpoint, p256dh, auth. Returns 204. |
| DELETE | /notifications/subscribe | JwtAuthGuard | Explicit opt-out: removes the Web Push subscription. Not called on normal logout — subscription persists so the service worker can deliver notifications while the member is logged out. Returns 204. |
| POST | /admin/assets | AdminGuard (ASSET_MANAGEMENT_WRITE) + Module: asset_management | Create a new asset. tagNumber auto-generated (AST-{YEAR}-{NNNN}) if not provided. Optional: serialNumber, manufacturer, model, warrantyExpiry, vendorName, vendorContact, departmentId. Returns 409 if tag already exists. |
| GET | /admin/assets?page=&limit=&status=&category=&maintenanceEnabled=&departmentId= | AdminGuard (ASSET_MANAGEMENT_READ) + Module: asset_management | Paginated asset list. Filterable by status, category (case-insensitive), maintenanceEnabled, and departmentId. Each record includes maintenanceSchedule and department. |
| GET | /admin/assets/checkouts?page=&limit= | AdminGuard (ASSET_MANAGEMENT_READ) + Module: asset_management | All currently active checkouts across all assets (returnedAt IS NULL), newest first. |
| GET | /admin/assets/:id | AdminGuard (ASSET_MANAGEMENT_READ) + Module: asset_management | Get asset with maintenanceSchedule and department. Maintenance history is paginated separately. |
| PATCH | /admin/assets/:id | AdminGuard (ASSET_MANAGEMENT_WRITE) + Module: asset_management | Partial update. Supports all asset fields including serialNumber, manufacturer, model, warrantyExpiry, vendorName, vendorContact, departmentId. |
| POST | /admin/assets/:id/maintenance-schedule | AdminGuard (ASSET_MANAGEMENT_WRITE) + Module: asset_management | Set or update the maintenance schedule. Sets maintenanceEnabled = true. Resets all notification timestamps. Body: frequencyUnit, frequencyValue, nextDueAt. |
| POST | /admin/assets/:id/maintenance-records | AdminGuard (ASSET_MANAGEMENT_WRITE) + Module: asset_management | Log a maintenance record. COMPLETED → asset ACTIVE + recalculates nextDueAt. IN_PROGRESS → asset UNDER_MAINTENANCE. |
| PATCH | /admin/assets/:id/inventory | AdminGuard (ASSET_MANAGEMENT_WRITE) + Module: asset_management | Set inventory breakdown. Sets inventoryEnabled = true. Body: inStorage, inUse, underRepair, writtenOff (all int ≥ 0). totalUnits = sum of all four. |
| GET | /admin/assets/:id/maintenance-records?page=&limit= | AdminGuard (ASSET_MANAGEMENT_READ) + Module: asset_management | Paginated maintenance history for an asset, newest first. |
| POST | /admin/assets/:id/checkouts | AdminGuard (ASSET_MANAGEMENT_WRITE) + Module: asset_management | Check out an asset. Requires checkedOutToMemberId or checkedOutToDepartmentId (at least one). Optional: expectedReturnAt, purpose, notes. Returns 400 if asset already has an active checkout, or asset is UNDER_MAINTENANCE, DECOMMISSIONED, or INACTIVE. On success: email notification sent to the checked-out member (if member checkout) and/or all HOD/D_HOD leads of the target department (if department checkout) via the asset-checkout-notification template. Notifications are fire-and-forget. |
| PATCH | /admin/assets/:id/checkouts/:checkoutId/return | AdminGuard (ASSET_MANAGEMENT_WRITE) + Module: asset_management | Mark a checkout as returned. Optional body: notes. Returns 400 if already returned. On success: email notification sent to the original recipient (member or department HOD/D_HOD leads) confirming the return. A RETURN_CONFIRMED row is recorded in asset_checkout_notifications. |
| GET | /admin/assets/:id/checkouts?page=&limit= | AdminGuard (ASSET_MANAGEMENT_READ) + Module: asset_management | Paginated checkout history for a specific asset, newest first. |
Overdue checkout reminders (daily cron at 08:00): OverdueCheckoutScheduler runs every day at 08:00 with a distributed Redis lock. It finds all active checkouts (returnedAt IS NULL) where expectedReturnAt < now. For each, it checks which day-thresholds defined in ASSET_OVERDUE_NOTIFICATION_DAYS have not yet been sent (tracked in the asset_checkout_notifications table with type = OVERDUE_REMINDER). Notifications go to the checked-out member and/or all HOD/D_HOD leads of the checked-out department. Set ASSET_OVERDUE_NOTIFICATION_DAYS= (empty) to disable all overdue reminders.
7. Check-In Flow
POST /attendances/checkin
Body: { serviceSlotId, location? }
Step-by-step:
-
Load slot — fetches
ServiceSlotwith relationsevent,config,config.defaultVenue,venueOverride. Throws 404 if not found. -
Load member — fetches the authenticated member with
workerProfile. -
Assert active — throws 400 if
member.status = INACTIVE. Also throws if the member is a WORKER withworkerProfile.status = INACTIVE. -
Resolve config —
EventService.resolveSlotConfig(slot)merges per-slot overrides over EventConfig values, includingformat(slot.formatOverride ?? config.defaultFormat). Throws 400 if no config, or if the resolvedformatisIN_PERSONwith no resolvable venue. -
Worker location — workers must provide
locationcoordinates, but only when the resolvedformatisIN_PERSON. AnONLINE-resolved slot never requires location from anyone. Throws 400 iflocationis absent for a WORKER checking into anIN_PERSONslot. -
Duplicate check — throws 400 if an attendance record already exists for
(member, event). One record per event, regardless of which slot the member enters. -
Validate window:
- Workers: window opens at
startTime + workerCheckinStartOffsetSeconds(typically negative) - Members: window opens at
startTime + memberCheckinStartOffsetSeconds - Both close at
startTime + checkinStopOffsetSeconds
- Workers: window opens at
-
Validate location (if location provided AND the resolved venue is non-null): Calculates Haversine distance between submitted coordinates and the venue’s
latitude/longitude. If distance exceedsallowedDistanceInMetersand enforcement is on for this tenant (AttendanceSettingsService.isEnabled()— see “Attendance Distance Check Setting” below; no longer a single globalENFORCE_DISTANCE_CHECK=truefor every tenant), throws 400. Never runs for anONLINE-resolved slot, since its resolved venue is null. -
Resolve status:
- Member → always
PRESENT - Worker before late threshold →
PRESENT - Worker at or after
startTime + workerLateOffsetSeconds→LATE
- Member → always
-
Save record — creates
Attendancewith references to botheventandserviceSlot,roleAtCheckinsnapshot, and optional location.
8. Automated Absence Marking
A cron job runs every 5 minutes (EVERY_5_MINUTES).
Logic:
- Finds all
Eventrecords whereattendanceMarked = falseANDendTime < now(the precise instant, not the date-onlyendDate) AND the event has at least one service slot. Served by a partial index (IDX_events_end_time_unmarked,end_time WHERE attendance_marked = false) so the query stays cheap regardless of how much event history a tenant accumulates — a plain index onend_timealone would match nearly every past event, not just the small rolling set still awaiting marking. - For each event:
- Gets all members (ACTIVE, role=MEMBER) who have no
PRESENTorLATEattendance record for the event → creates oneABSENTrecord per member referencing the event (serviceSlot = null). - Gets all workers (ACTIVE, role=WORKER) who have no
PRESENTorLATErecord for the event:- Checks
request_leavetable: if the worker has an APPROVED leave whosedate_from ≤ event.eventDate ≤ date_to→ createsON_LEAVErecord.- PostgreSQL
DATEvalues may be hydrated asYYYY-MM-DDstrings; leave-date comparisons preserve that form (and normalizeDatevalues) to avoid timezone shifts.
- PostgreSQL
- Otherwise → creates
ABSENTrecord.
- Checks
- Gets all members (ACTIVE, role=MEMBER) who have no
- All absence records for the event are saved in a single DB transaction.
- Sets
event.attendanceMarked = trueso the job skips it next run. - Dispatches a
post-eventjob to thefollow-upBull queue for thank-you emails and optional online-confirm notifications (fire-and-forget, inside the loop but outside the transaction).
9. Role & Permission Matrix
The system has two distinct access dimensions:
- Church role (
MemberRoleEnumon the Member entity) — controls mobile-app routes:MEMBERorWORKER. - Admin portal access (
Adminentity +AdminRolepermissions) — controls admin web portal routes viaAdminGuard.
A church worker can also have admin access. They pass @Roles(WORKER) routes via their church role and pass
@UseGuards(AdminGuard) routes via their Admin record.
Mobile App (church role)
| Action | MEMBER | WORKER |
|---|---|---|
| Sign up / login | ✓ | ✓ |
| View own profile | ✓ | ✓ |
| Check in to service | ✓ | ✓ |
| View own attendance | ✓ | ✓ |
| View own class enrolments | ✓ | ✓ |
| View announcement feed | ✓ | ✓ |
| Worker dashboard | — | ✓ |
| Request leave | — | ✓ |
| View own leave history | — | ✓ |
| View department leave | — | ✓ (lead only) |
| SS class actions (create/update/assign members) | — | ✓ (SS-dept or class teacher) |
| SS session management | — | ✓ (SS-dept or class teacher) |
| SS self-mark attendance | enrolled member | enrolled member |
| SS bulk-mark / roster | — | ✓ (SS-dept or class teacher) |
| CC child/guardian management | — | ✓ (CC-dept worker) |
| CC check-in / check-out / flag | — | ✓ (CC-dept worker) |
| Register first-timers | — | ✓ (FOLLOW_UP-dept worker) |
| View / update own follow-up tasks | — | ✓ (FOLLOW_UP-dept worker) |
| Confirm online attendance | ✓ | ✓ |
| Submit a prayer request / testimony | ✓ | ✓ |
| View public testimony feed | ✓ | ✓ |
| Prayer team inbox (view/update request status) | — | ✓ (PRAYER-dept worker or Clergy) |
| Rate a service (own rating only) | ✓ | ✓ |
| Browse and sign up for volunteer opportunities | ✓ | ✓ |
| Browse, join, and leave fellowships | ✓ | ✓ |
| Record attendance for a fellowship (leader only) | ✓ (if leader) | ✓ (if leader) |
Admin Portal (AdminGuard + permission)
| Action | Permission |
|---|---|
| List / view members | MEMBERS_READ |
| Create a member account directly | MEMBERS_WRITE |
| Promote / revoke workers, reset passwords | MEMBERS_WRITE |
| View events / configs | EVENTS_READ |
| Create / update / delete events & configs | EVENTS_WRITE |
| Create / update / delete venues | VENUES_WRITE |
| View departments / leads | DEPARTMENTS_READ |
| Create / update / delete departments & leads | DEPARTMENTS_WRITE |
| View all attendance, leaderboard | ATTENDANCE_READ |
| Correct an attendance record status | ATTENDANCE_WRITE |
| Mark/backfill attendance for a member (admin portal) | ATTENDANCE_WRITE |
| View all leave requests | LEAVE_READ |
| Approve / reject leave | LEAVE_WRITE |
| View classes & enrolments | CLASSES_READ |
| Create / update / delete classes & enrolments | CLASSES_WRITE |
| View announcements | ANNOUNCEMENTS_READ |
| Create / update / delete announcements | ANNOUNCEMENTS_WRITE |
| View pastoral notes & analytics | NOTES_READ |
| Create / update / delete notes | NOTES_WRITE |
| Admin dashboard | DASHBOARD_READ |
| SS delete class/session | SUNDAY_SCHOOL_WRITE |
| CC age/class group CRUD + recompute | CHILDREN_CHURCH_WRITE |
| CC slot-level check-in report | CHILDREN_CHURCH_READ |
| View audit logs | AUDIT_READ |
| View admin users & roles | ADMIN_READ |
| Create / update / delete admin users & roles | ADMIN_WRITE |
| View own admin profile | (any active admin) |
| View tithe batches, records, disputes | FINANCE_READ |
| Upload tithes, resolve disputes, approve/reject requests, attach proof | FINANCE_WRITE |
| View finance categories and requests | FINANCE_READ |
| View first-timers and follow-up tasks | FOLLOW_UP_READ |
| Register first-timers, reassign / bulk-update tasks | FOLLOW_UP_WRITE |
| View service attendance headcounts and trends | HEADCOUNT_READ |
| Record and correct physical attendance headcounts | HEADCOUNT_WRITE |
| View prayer config, rules, roster, and meetings | PRAYER_READ |
| Manage prayer days, rules, assignments, and roster | PRAYER_WRITE |
| View prayer requests and testimonies | PRAYER_READ |
| Update a prayer request’s status | PRAYER_WRITE |
| View pregnancy prayer cases | PRAYER_READ |
| Update a pregnancy prayer case’s status | PRAYER_WRITE |
| View evangelism converts and follow-up history | EVANGELISM_READ |
| Reassign convert follow-up, link convert to member | EVANGELISM_WRITE |
| View sermon archive entries | SERMON_READ |
| Create/edit/delete sermons, trigger “we’re live” | SERMON_WRITE |
| View games, questions, sessions, and leaderboards | GAMES_READ |
| Create/edit games and questions, control live sessions | GAMES_WRITE |
| View aggregate service ratings and anonymized comments | SERVICE_RATING_READ |
| Reveal identity behind a rating comment; delete/hide it | SERVICE_RATING_MODERATE |
| View volunteer opportunities and sign-up rosters | VOLUNTEER_READ |
| Create, edit, and cancel volunteer opportunities | VOLUNTEER_WRITE |
| View small groups, rosters, and attendance history | SMALL_GROUP_READ |
| Create, edit, delete small groups; assign leaders; remove members | SMALL_GROUP_WRITE |
10. Environment Variables
All variables are validated by Joi at startup (src/config/env.validation.ts). Missing required variables crash the
process with a clear error before any HTTP traffic is accepted.
For the database backup/restore strategy (not an env var concern, but adjacent operational documentation that
didn’t exist anywhere before), see docs/BACKUP_AND_RESTORE.md.
Graceful shutdown: main.ts calls app.enableShutdownHooks(['SIGTERM', 'SIGINT']), so in-flight requests are
allowed to finish and NestJS lifecycle hooks (e.g. closing the DB pool, Redis, Bull queues) run before the process
exits. Relevant when the orchestrator sends SIGTERM on deploy/scale-down — without this, connections would be cut
mid-request.
Provider webhooks bypass the global JWT guard: the YouTube WebSub
callbacks (GET/POST /integrations/youtube/callback), POST /webhooks/billing, and
POST /webhooks/giving/:tenantId/:provider are decorated @Public(). Providers never send a bearer token, so the
global JwtAuthGuard would 401 them before their own signature verification (HMAC / X-Hub-Signature /
x-paystack-signature / verif-hash / x-korapay-signature / Stripe-Signature) ever runs. @Public() only opts
a route out of JWT auth — it does not skip the handler’s own signature check. All three are also excluded from
TenantMiddleware (§4.3) — same no-Host-header reasoning, though the giving webhook is the one exception with an
actual tenant identifier on the route itself (:tenantId), since unlike billing’s single shared platform-wide
route, each tenant has their own BYOK giving-checkout credentials to resolve.
Migration history was squashed (2026-07-31): the 107 incremental migrations that had accumulated since the
project’s first commit were replaced with a single src/migrations/1790553600000-Baseline.ts, generated via
pg_dump --schema-only (plus a --data-only dump of the static reference tables: admin_roles, class_types,
prayer_programs, prayer_schedule_rules) against a database that had every prior migration applied. The baseline
was verified to produce a byte-identical schema and seed dataset before the switch. The original files are kept in
src/migrations/legacy/ for historical reference — that folder is outside the glob TypeORM scans
(src/data-source.ts’s migrations path is non-recursive), so they no longer run. This was a pre-production
one-time cleanup; per CLAUDE.md, no migration is ever edited or re-squashed after it has shipped
to a real environment.
Runtime
| Variable | Default | Description |
|---|---|---|
NODE_ENV |
development |
development | production | test |
PORT |
3000 |
HTTP port the server listens on |
CORS_ORIGINS |
— (required) | Comma-separated extra CORS allowlist for origins outside APP_BASE_DOMAIN (marketing site, docs, ops tooling) — every subdomain of APP_BASE_DOMAIN is allowed dynamically regardless of this list, see “CORS origin validation” under Multi-Tenant Request Scoping |
APP_NAME |
discuva-api |
Service name used in logs and process identification |
APP_BASE_DOMAIN |
localhost |
Suffix TenantMiddleware strips from the Host header to find a tenant’s subdomain (§5 Multi-Tenant Request Scoping) — *.localhost resolves to 127.0.0.1 with no /etc/hosts changes, so the default works out of the box in dev |
Branding (used in email templates and generated PDFs)
PRODUCT_NAME is genuinely platform-wide (the SaaS product name) and is always read from here. CHURCH_NAME/
CHURCH_ADDRESS/CHURCH_TAGLINE/LOGO_URL/CURRENCY_CODE are now only the fallback for a tenant that hasn’t
set its own name/address/tagline/logoUrl/currency (per-field, not all-or-nothing) — see
EmailQueueService.resolveBrandingData(), PdfService.resolveBranding(), and TenantCurrencyService.resolveCurrencyCode()
under Utility/Infrastructure above. All three share the same tenant-branding:${tenantId} cache entry (one Tenant
lookup serves all three). TenantCurrencyService is also used by FinanceRequestService (Excel export header,
approve/reject/submitted notification emails) and AnnualGivingStatementScheduler — both sendForMember() (the
on-demand POST /finance/me/giving-statement/send path, which always has real CLS context from its HTTP caller)
and the nightly @Cron path, run(), which now resolves each active tenant’s own currency correctly since
sendAnnualStatements() wraps run() in forEachActiveTenant() (see “Scheduler tenant iteration” under
Multi-Tenant Request Scoping) — every @Cron scheduler that touches tenant-scoped data now loops per tenant.
CURRENCY_LOCALE has no tenant-scoped equivalent (Tenant has no locale column) and stays a pure global default —
used by PdfService for number formatting and, unrelatedly, by EventReminderService/TitheService for date/time
formatting (those two never touched currency, so needed no change).
Every generated PDF’s header now embeds the tenant’s actual logo, not just its name/tagline text.
PdfService.resolveBranding() fetches tenant.logoUrl (when set) and base64-encodes it into PdfBranding.logoImage
once per PDF — drawPageHeader() (shared by every report type: session/event reports, department goals, giving
statements, etc.) then calls jsPDF’s addImage() with it, shifting the church name/tagline right to make room.
Cached by URL under pdf-logo-image:${logoUrl} (same TTL/mechanism as the branding cache) so a broken or
unreachable logo URL doesn’t retry on every PDF request — it just caches the resulting null and falls back to
the original text-only header. Only PNG/JPEG are embeddable (jsPDF’s addImage needs a format jsPDF can decode);
an SVG or unsupported logo format also falls back to text-only rather than failing PDF generation. The
addImage() call itself is wrapped in its own try/catch too, so a corrupt image can’t break the rest of the
header/report either.
| Variable | Default | Description |
|---|---|---|
PRODUCT_NAME |
Discuva |
Product name shown in email subjects — always global |
CHURCH_NAME |
RCCG Discovery Centre |
Fallback when a tenant’s own name is unset |
CHURCH_ADDRESS |
62 Igi Olugbin Street, Bariga. Lagos, Nigeria |
Fallback when a tenant’s own address is unset |
CHURCH_TAGLINE |
Destinies discovered, Champions raised |
Fallback when a tenant’s own tagline is unset — PDFs only |
LOGO_URL |
Cloudinary default logo asset | Fallback when a tenant’s own logoUrl is unset |
CURRENCY_CODE |
NGN |
Fallback when a tenant’s own currency is unset |
CURRENCY_LOCALE |
en-NG |
Always global — no tenant-scoped equivalent exists |
Error Tracking (Sentry)
Optional. src/instrument.ts is imported as the very first line of main.ts (before any other import — required
for the SDK’s automatic instrumentation of http/pg/etc. to attach before those modules are first loaded
elsewhere in the dependency graph) and calls Sentry.init() only when both SENTRY_DSN is set and
SENTRY_ENABLED is true. HttpExceptionFilter (the single global exception filter) calls
Sentry.captureException() only for 5xx responses and genuinely unhandled (non-HttpException) errors — routine
4xx validation/auth errors are never reported, matching the filter’s existing error/warn log-level split.
Sentry.captureException() is safe to call even when init() never ran (unset DSN) — it’s a no-op, not a crash.
| Variable | Default | Description |
|---|---|---|
SENTRY_DSN |
— (unset) | Sentry project DSN. Unset disables error reporting entirely — the default for local dev. |
SENTRY_ENABLED |
true |
Separate kill switch on top of SENTRY_DSN — set to false to mute reporting without removing the DSN. |
SENTRY_ENVIRONMENT |
NODE_ENV |
Sentry environment tag. Falls back to NODE_ENV, then 'development'. |
Database
| Variable | Default | Description |
|---|---|---|
DATABASE_HOST |
— (required) | Postgres host |
DATABASE_PORT |
5432 |
Postgres port |
DATABASE_USER |
— (required) | DB username |
DATABASE_PASSWORD |
— (required) | DB password |
DATABASE_NAME |
— (required) | DB name |
DATABASE_SSL |
false |
Enable SSL (rejectUnauthorized=false) |
DATABASE_LOGGING |
false |
Enable TypeORM query logging |
DATABASE_DEBUG |
false |
Enable TypeORM debug-level query logging |
DATABASE_POOL_SIZE |
50 |
Max connections in the pool |
DATABASE_POOL_MIN |
0 |
Min idle connections kept alive. 0 lets the database scale to zero when the app is quiet |
DATABASE_POOL |
transaction |
Pool mode for PgBouncer/Supavisor: transaction | session | statement |
DATABASE_POOL_LOG |
false |
Log pool connection acquire/release events |
JWT
| Variable | Default | Description |
|---|---|---|
JWT_SECRET |
— (required, min 32 chars) | Access token signing secret |
JWT_EXPIRY_IN |
1h |
Access token expiry (e.g. 1h, 15m, 7d) |
REFRESH_JWT_SECRET |
— (required, min 32 chars) | Refresh token signing secret |
REFRESH_JWT_EXPIRY_IN |
7d |
Refresh token expiry |
SESSION_MAX_AGE_DAYS |
30 |
Absolute session lifetime in days — refresh rejected after this regardless of rotation |
PLATFORM_ADMIN_JWT_SECRET |
— (required, min 32 chars) | Platform-admin token signing secret — deliberately separate from JWT_SECRET (§5 Platform Admin) |
PLATFORM_ADMIN_JWT_EXPIRY_IN |
1h |
Platform-admin token expiry |
PLATFORM_ADMIN_REFRESH_JWT_SECRET |
— (required, min 32 chars) | Platform-admin refresh-token signing secret — separate from both PLATFORM_ADMIN_JWT_SECRET and the tenant-side REFRESH_JWT_SECRET |
PLATFORM_ADMIN_REFRESH_JWT_EXPIRY_IN |
7d |
Platform-admin refresh-token expiry — how long a session survives without a fresh password login |
CREDENTIALS_ENCRYPTION_KEY |
— (required, min 32 chars) | Encrypts tenant BYOK SMS/email provider credentials at rest (§5 Communication Providers) — rotating this makes existing encrypted credentials unreadable |
Set EMAIL_PROVIDER to choose the platform-wide default provider. This is only the fallback used when a tenant has
no BYOK config of its own (see Communication Providers) — a tenant can independently pick any of the five providers
below regardless of this setting. Only the variables for the active default provider are required at runtime;
SmtpProvider has no platform default at all (BYOK-only).
| Variable | Default | Description |
|---|---|---|
EMAIL_PROVIDER |
gmail |
Platform-default provider: gmail | resend |
EMAIL_FROM |
— | Sender address used for all outbound email (overrides EMAIL_USER) |
Gmail SMTP
| Variable | Default | Description |
|---|---|---|
EMAIL_HOST |
— | SMTP host |
EMAIL_PORT |
— | SMTP port |
EMAIL_SECURE |
false |
true for port 465, false for 587 |
EMAIL_SERVICE |
— | e.g. gmail (optional if HOST/PORT are set) |
EMAIL_USER |
— | SMTP username / sender address |
EMAIL_PASSWORD |
— | SMTP password / app password |
Resend
| Variable | Default | Description |
|---|---|---|
RESEND_API_KEY |
— | Resend API key (re_*…) |
Custom SMTP
No platform-default env vars — this provider is BYOK-only (providerId: 'smtp') and throws if called without a
tenant’s own {host, port?, secure?, user, password} credentials. Use this when a tenant wants to fully bring their
own mail server; use gmail’s BYOK host override instead when they just want a different domain on otherwise
platform-managed SMTP settings.
SendGrid
| Variable | Default | Description |
|---|---|---|
SENDGRID_API_KEY |
— | SendGrid API key, platform default |
SENDGRID_BASE_URL |
https://api.sendgrid.com |
Override for region failover / test doubles — matches every sibling provider’s own *_BASE_URL convention |
Mailgun
| Variable | Default | Description |
|---|---|---|
MAILGUN_API_KEY |
— | Mailgun API key, platform default |
MAILGUN_DOMAIN |
— | Mailgun sending domain, platform default |
MAILGUN_BASE_URL |
https://api.mailgun.net/v3 |
Override for the EU region (https://api.eu.mailgun.net/v3) |
Email Category Gates
Each flag defaults to true. Set to false to suppress that category of emails (useful when on Resend’s free tier to stay under the daily limit). Auth and admin emails are not gated.
| Variable | Default | Category suppressed |
|---|---|---|
EMAIL_ATTENDANCE_CHECKIN_ENABLED |
true |
Attendance check-in receipts |
EMAIL_BIRTHDAY_ENABLED |
true |
Birthday greetings |
EMAIL_EVENT_REMINDER_ENABLED |
true |
Event slot reminders |
EMAIL_PRAYER_REMINDER_ENABLED |
true |
Prayer roster reminders |
EMAIL_FOLLOW_UP_ENABLED |
true |
Follow-up task emails |
EMAIL_ASSET_ALERTS_ENABLED |
true |
Asset maintenance/overdue alerts |
EMAIL_GIVING_RECEIPT_ENABLED |
true |
Tithe receipts and statements |
EMAIL_FINANCE_ALERTS_ENABLED |
true |
Budget and pledge alerts |
EMAIL_SESSION_REPORT_ENABLED |
true |
Session completion reports |
EMAIL_INCIDENT_REPORT_ENABLED |
true |
Incident report notifications |
EMAIL_CHILDREN_CHURCH_ENABLED |
true |
Children church pickup codes |
EMAIL_LOGIN_ALERT_ENABLED |
true |
New device login notifications |
EMAIL_PASTOR_FEEDBACK_ENABLED |
true |
Weekly feedback reminders and pastor-response notifications |
EMAIL_ASSIGNMENT_REMINDER_ENABLED |
true |
Assignment due-date reminders |
EMAIL_CLASS_SESSION_REMINDER_ENABLED |
true |
Class next-session reminders |
EMAIL_FORM_SUBMISSION_ENABLED |
true |
Admin notification on a new form submission (also requires the form’s own notifyOnSubmission to be on) |
EMAIL_SUNDAY_SCHOOL_QA_ENABLED |
true |
Sunday School question-asked / question-answered notifications |
EMAIL_SUNDAY_SCHOOL_ATTENDANCE_ENABLED |
true |
Sunday School check-in-open and weekly absentee pushes |
EMAIL_TRAINING_CLASSES_ENABLED |
true |
Training class join-request decisions and certificate-ready pushes |
EMAIL_EVANGELISM_ENABLED |
true |
Evangelism outreach-team and convert-assignment pushes |
EMAIL_NOTES_ENABLED |
true |
Notes reminder pushes (evening after a service, Monday weekly step) |
EMAIL_DEPARTMENT_GOAL_ACTIVITY_ENABLED |
true |
Department Goals approval decisions and comments (push-only for now — the flag exists for consistency and to gate a future email leg) |
Auth / OTP
| Variable | Default | Description |
|---|---|---|
OTP_TTL_SECONDS |
900 |
How long a reset OTP stays valid (15 min) |
FORGOT_PASSWORD_MAX_ATTEMPTS |
3 |
Max OTP requests per rate-limit window |
FORGOT_PASSWORD_WINDOW_SECONDS |
3600 |
Rate-limit window for forgot-password (1 hr) |
LOGIN_MAX_ATTEMPTS |
5 |
Max failed login attempts before lockout |
LOGIN_WINDOW_SECONDS |
900 |
Lockout window duration (15 min) |
DEVICE_RESET_MAX_ATTEMPTS |
3 |
Max self-service device reset requests per window per email |
DEVICE_RESET_WINDOW_SECONDS |
86400 |
Rate-limit window for device resets (24 hr) |
OTP_VERIFY_MAX_ATTEMPTS |
5 |
Max wrong-code guesses per account against a live OTP (password reset, device reset, email change) before a 429 lockout for the rest of that OTP’s OTP_TTL_SECONDS window — separate from FORGOT_PASSWORD_MAX_ATTEMPTS/DEVICE_RESET_MAX_ATTEMPTS, which only cap how often a new code can be requested |
Global Rate Limiting
Applied to every endpoint via ThrottlerGuard as a global APP_GUARD. Returns HTTP 429 when exceeded. The
GET /health endpoint is exempt via @SkipThrottle().
| Variable | Default | Description |
|---|---|---|
THROTTLE_TTL_MS |
60000 |
Sliding window in milliseconds (1 min) |
THROTTLE_LIMIT |
100 |
Max requests per window per IP |
Redis
Used for two purposes: the distributed cache (CacheService) and the Bull email job queue (EmailQueueService).
Both use the same Redis server and the same logical database — Bull keys are namespaced bull:* and do not collide
with application cache keys.
| Variable | Default | Description |
|---|---|---|
REDIS_HOST |
localhost |
Redis server hostname |
REDIS_PORT |
6379 |
Redis server port |
REDIS_PASSWORD |
— | Redis auth password (leave blank if no auth) |
REDIS_DB |
0 |
Logical database index (0–15) |
Timezone
| Variable | Default | Description |
|---|---|---|
TIMEZONE |
Africa/Lagos |
IANA timezone name. Drives two things: (1) every daily/specific-time @Cron job below runs in this timezone via its timeZone option, not the server process’s own clock; (2) DateService.startOfDay()/endOfDay() (used by getTotalCheckInsToday()) compute day boundaries in this timezone. Does not change process.env.TZ — the server process itself still runs in whatever timezone its host/container is set to (UTC in this deployment); only these two call sites are timezone-aware. All “runs daily at HH:MM” times documented below are in this configured timezone. |
Cache TTLs
| Variable | Default | Description |
|---|---|---|
CACHE_TTL_REFERENCE_SECONDS |
300 |
TTL for reference data: departments, venues, event configs (5 min) |
CACHE_TTL_LEADERBOARD_SECONDS |
90 |
TTL for attendance leaderboard |
Birthday Wishes
| Variable | Default | Description |
|---|---|---|
WISH_DAILY_LIMIT |
20 |
Max birthday wishes a single user can send per day |
Attendance / Check-In
| Variable | Default | Description |
|---|---|---|
ENFORCE_DISTANCE_CHECK |
false |
No longer read directly by AttendanceService — now only the fallback-of-the-fallback for PlatformSettingKey.ENFORCE_DISTANCE_CHECK_DEFAULT (see “Attendance Distance Check Setting” below) when no PlatformSetting row exists yet either. Kept as a real env var (not removed) specifically so an environment that already has it set isn’t silently reset to false the moment this shipped. |
ONLINE_CHECKIN_WINDOW_HOURS |
3 |
Default hours after online-confirm emails are sent during which members can confirm online attendance; each church can override it in minutes (Event Config → Online Attendance Confirmation, 15 min – 7 days); the email shows it as e.g. “2 hours 30 minutes” (window_label) |
FOLLOW_UP_DUE_DAYS |
3 |
Days from task creation before a follow-up task is considered overdue (sets dueDate) |
FOLLOW_UP_STALE_DAYS |
7 |
Days of inactivity before an open task is flagged stale (daily cron + stale endpoint) |
Service Programme
| Variable | Default | Description |
|---|---|---|
SERVICE_SLOT_CAUTION_THRESHOLD_RATIO |
0.25 |
Fraction of a slot’s allocated time remaining at which the presentation view switches to the “Wrapping Up” caution state. Resolved server-side and returned as cautionThresholdRatio on GET /service-session/:code/state — never duplicated as frontend config. |
Default Seed Data (applied on first boot)
| Variable | Default | Description |
|---|---|---|
DEFAULT_ADMIN_EMAIL |
— | Email for the seeded default admin account |
DEFAULT_ADMIN_PASSWORD |
— | Password for the seeded default admin account |
DEFAULT_PLATFORM_ADMIN_EMAIL |
— | Email for the seeded first platform admin (npm run seed:platform-admin) |
DEFAULT_PLATFORM_ADMIN_PASSWORD_HASH |
— | Argon2 hash for the seeded first platform admin (generate via npm run hash:password) |
Removed: DEFAULT_VENUE_NAME/DEFAULT_VENUE_ADDRESS/DEFAULT_VENUE_LATITUDE/DEFAULT_VENUE_LONGITUDE/
DEFAULT_EVENT_CONFIG_NAME/DEFAULT_EVENT_ALLOWED_DISTANCE_IN_METERS/WORKER_CHECKIN_START_OFFSET_SECONDS/
WORKER_LATE_OFFSET_SECONDS/MEMBER_CHECKIN_START_OFFSET_SECONDS/CHECKIN_STOP_OFFSET_SECONDS — these only ever
fed DefaultEventConfigSeed, a boot-time seed that ran outside any tenant CLS context and wrote into orphaned
public.venues/public.event_config rows no live tenant schema ever reads (each tenant gets its own venues/
event_config rows via TenantSchemaGenesis/normal admin setup instead). Confirmed dead — deleted along with the
seed service rather than fixed. The four checkin-offset names now live only as columns on the tenant-scoped
EventConfig entity (workerCheckinStartOffsetSeconds etc.), editable per-tenant via EventConfigController.
Cloudinary (file uploads)
Used for finance request attachments and payment proofs.
| Variable | Default | Description |
|---|---|---|
CLOUDINARY_CLOUD_NAME |
— (required) | Cloudinary account cloud name |
CLOUDINARY_API_KEY |
— (required) | Cloudinary API key |
CLOUDINARY_API_SECRET |
— (required) | Cloudinary API secret |
MAX_FILE_UPLOAD_BYTES |
5242880 |
Fallback default for routes with no more specific category — incident report photos, member bulk-import spreadsheets |
Logo/appearance, avatar, class-material, finance-proof, form-attachment, and page-image upload limits are not
env vars — they’re platform-admin-configurable via PlatformSettingKey.MAX_LOGO_UPLOAD_MB/MAX_AVATAR_UPLOAD_MB/
MAX_CLASS_MATERIAL_UPLOAD_MB/MAX_FINANCE_PROOF_UPLOAD_MB/MAX_FORM_ATTACHMENT_UPLOAD_MB/
MAX_PAGE_IMAGE_UPLOAD_MB (see “Platform Settings” under the platform-admin
section) and enforced via DynamicLimitedFileInterceptor, which rewrites Multer’s generic “File too large” error
into "The uploaded file exceeds the maximum allowed size of {N} MB..." using the live limit for that route —
not a guess. HttpExceptionFilter’s own PayloadTooLargeException handling (using MAX_FILE_UPLOAD_BYTES) is a
fallback only, for the handful of upload routes not wrapped in LimitedFileInterceptor/DynamicLimitedFileInterceptor
at all (e.g. finance-admin/tithe-admin/reconciliation attachment routes, which currently have no configured
size limit).
| TITHE_PROOF_EXPIRY_DAYS | 90 | Days after which a tithe payment proof is purged from Cloudinary and DB |
| ASSET_OVERDUE_NOTIFICATION_DAYS | 1,3,7 | Comma-separated day thresholds for overdue checkout reminders. Leave empty to disable. |
Web Push (VAPID)
Generate keys once with npx web-push generate-vapid-keys and store permanently.
| Variable | Default | Description |
|---|---|---|
VAPID_PUBLIC_KEY |
— (required) | VAPID public key — also exposed to the PWA frontend as NEXT_PUBLIC_VAPID_PUBLIC_KEY |
VAPID_PRIVATE_KEY |
— (required) | VAPID private key — backend only, never exposed to clients |
VAPID_SUBJECT |
— (required) | VAPID subject — must be a mailto: or https:// URI (e.g. mailto:admin@example.com) |
App URLs (embedded in emails)
| Variable | Description |
|---|---|
LOGIN_URL |
Mobile app login URL — embedded in member/worker welcome and notification emails (required) |
ADMIN_LOGIN_URL |
Admin portal login URL — embedded in the admin welcome email on role grant (required) |
SUPPORT_FORM_URL |
Support contact form URL |
EXPLAINER_VIDEO_ANDROID_URL |
Android onboarding video URL |
EXPLAINER_VIDEO_IOS_URL |
iOS onboarding video URL |
Bull Board (optional)
| Variable | Default | Description |
|---|---|---|
BULL_BOARD_USER |
— | Username for the Bull Board queue dashboard at /queues. If unset, dashboard is not mounted. |
BULL_BOARD_PASSWORD |
— | Password for the Bull Board queue dashboard. Required alongside BULL_BOARD_USER. |
SMS
Pure BYOK (see SMS Module above) — no platform-default credentials for any SMS vendor, so there’s nothing here for
Twilio at all (its accountSid/authToken/fromNumber only ever exist as a tenant’s own encrypted BYOK config).
Termii’s API host is the one exception: infrastructure, not a secret, so it stays env-driven.
| Variable | Default | Description |
|---|---|---|
TERMII_BASE_URL |
https://api.ng.termii.com |
Termii API base URL — same for every tenant’s Termii account, BYOK or not |
YouTube Live Detection (optional, platform-wide only)
Channel id and Data API key are not set here — they’re per-tenant, via PUT /v1/youtube-integration (see
“YouTube Live Detection” above), with no platform-wide fallback for the key. Both below are platform-wide and both
optional — leave unset to skip WebSub subscription entirely and rely on the Sermon Module’s manual “Announce Live”
trigger instead.
| Variable | Default | Description |
|---|---|---|
YOUTUBE_WEBSUB_CALLBACK_URL |
— (optional) | Publicly reachable URL for GET/POST integrations/youtube/callback (must be internet-facing for Google’s hub to reach it) — one physical endpoint shared by every tenant |
YOUTUBE_WEBSUB_SECRET |
— (optional) | Shared HMAC secret sent as hub.secret on subscribe; the hub signs every notification with it (X-Hub-Signature), which the callback verifies. Required alongside the callback URL — without it, subscribe() never registers a live subscription and the callback rejects everything it receives. |
PUBSUBHUBBUB_URL |
https://pubsubhubbub.appspot.com/subscribe |
Google’s PubSubHubbub hub endpoint YoutubeSubscriptionService posts subscribe/unsubscribe requests to |
Pages: Gallery Folder Sync (optional, platform-wide)
Unlike YouTube Live Detection above, this is not per-tenant — one read-only Drive API v3 key, shared by
every tenant’s Gallery sections, since listing files in a public folder needs no tenant-specific
authorization. Leave unset to skip folder sync entirely; a GALLERY section with syncFolderUrl set just
falls back to its manually-saved images, same as before this existed.
| Variable | Default | Description |
|---|---|---|
GOOGLE_DRIVE_API_KEY |
— (optional) | A Google Cloud API key with the Drive API enabled, used only for a read-only files.list call against whatever public folder an admin points a Gallery section at (GalleryFolderSyncService) — no OAuth, no connected account |
Billing: Paystack / Flutterwave (optional, platform-wide)
All optional — a provider whose secret key isn’t set simply can’t be selected as ?provider= on a checkout call
(PaymentProviderRegistryService throws a clean 400, not a crash). Platform-wide, not tenant BYOK — see “Billing
& Checkout” above for why.
| Variable | Default | Description |
|---|---|---|
PAYSTACK_SECRET_KEY |
— (optional) | Paystack secret key, used both for API calls (Authorization: Bearer) and to compute the HMAC-SHA512 webhook signature |
PAYSTACK_BASE_URL |
https://api.paystack.co |
Paystack API base URL |
FLUTTERWAVE_SECRET_KEY |
— (optional) | Flutterwave secret key, used for API calls |
FLUTTERWAVE_SECRET_HASH |
— (optional) | Shared secret configured in the Flutterwave dashboard’s webhook settings — compared verbatim against the verif-hash header, not an HMAC key |
FLUTTERWAVE_BASE_URL |
https://api.flutterwave.com/v3 |
Flutterwave API base URL |
MONNIFY_API_KEY |
— (optional) | Monnify API key for platform billing; MK_TEST_… keys use the sandbox |
MONNIFY_SECRET_KEY |
— (optional) | Monnify secret key — sign-in and webhook signature (monnify-signature, HMAC-SHA512) |
MONNIFY_CONTRACT_CODE |
— (optional) | Monnify contract code the platform’s charges settle under |
DEFAULT_PAYMENT_PROVIDER |
paystack |
Which provider a checkout call uses when it doesn’t specify ?provider= explicitly (paystack, flutterwave, kora, monnify) |
SUBSCRIPTION_PERIOD_DAYS |
30 |
Renewal period CheckoutService.applyChargeSucceeded() extends currentPeriodEnd by per successful charge, for a billingInterval: 'monthly' plan |
ANNUAL_SUBSCRIPTION_PERIOD_DAYS |
365 |
Same, for a billingInterval: 'annual' plan |
GRACE_PERIOD_DAYS |
7 |
How long SubscriptionLapseScheduler keeps a PAST_DUE subscription’s features before downgrading to Free |
11. Enum Reference
MemberRoleEnum
MEMBER · WORKER
Admin portal access is not a member role — it is managed via the Admin entity and AdminRole.
AdminPermission
Granular permissions assigned to AdminRole records:
members:read · members:write · events:read · events:write · venues:read · venues:write ·
departments:read · departments:write · attendance:read · attendance:write · leave:read · leave:write · classes:read ·
classes:write · announcements:read · announcements:write · dashboard:read ·
sunday_school:read · sunday_school:write · children_church:read · children_church:write · admin:read ·
admin:write · audit:read · finance:read · finance:write · follow_up:read · follow_up:write ·
service_programme:read · service_programme:write · headcount:read · headcount:write ·
prayer:read · prayer:write · sms:read · sms:send · sermon:read · sermon:write
GET /enums returns these as both a flat adminPermissions list (value + label) and a grouped adminPermissionGroups list (group name + permissions with value, label, and description) — use the grouped form to render the permission assignment UI. sms:read/sms:send are grouped under “SMS Messaging”.
MemberImportJobStatus
READY_FOR_REVIEW · COMMITTED
MemberImportRowStatus
PENDING · CREATED · FAILED
MemberStatusEnum / WorkerStatusEnum
ACTIVE · INACTIVE
GenderEnum
MALE · FEMALE
MaritalStatusEnum
SINGLE · MARRIED · DIVORCED · WIDOWED
AttendanceStatusEnum
PRESENT · LATE (workers only) · ABSENT · ON_LEAVE (workers only) · ATTENDED_ONLINE
LeaveStatusEnum
PENDING · APPROVED · REJECTED
ChurchClassTypeEnum
BELIEVERS · BAPTISMAL · WORKERS_IN_TRAINING · BIBLE_COLLEGE · SCHOOL_OF_DISCIPLESHIP
Legacy — not used at runtime. Class types are now admin-creatable via the ClassType entity (see Data Models above); this enum only documents the 5 values the AddClassTypesTable migration seeded as rows, for reference when reading that migration.
EnrollmentStatusEnum
IN_PROGRESS · COMPLETED · CANCELLED
AnnouncementAudienceEnum
ALL · WORKERS_ONLY · MEMBERS_ONLY · DEPARTMENT · INDIVIDUAL · GROUP · CLASS
NoteTypeEnum (path param values)
child_naming · child_dedication · marriage · baptism
EventRecurrencePatternEnum
daily · weekly · monthly
OrderBy (Events)
eventDate · createdAt · updatedAt
DepartmentCapability
A fixed, validated enum — not free text. Department.capabilities: DepartmentCapability[];
CreateDepartmentDto/UpdateDepartmentDto validate every entry with @IsEnum(DepartmentCapability, { each: true }).
A department can hold any combination of these; each is named after the action it unlocks rather than after a
department, so it stays meaningful regardless of what a given church calls the department that holds it.
MANAGE_SUNDAY_SCHOOL · MANAGE_CHILDREN_CHURCH · MANAGE_PRAYER_REQUESTS · MANAGE_EVANGELISM_CONVERTS · MANAGE_FOLLOW_UP · FRONT_DESK_OPERATIONS
SundaySchoolAttendanceStatus
PRESENT · ABSENT · EXCUSED
MeetingDayEnum (Sunday School)
SUNDAY · MONDAY · TUESDAY · WEDNESDAY · THURSDAY · FRIDAY · SATURDAY
GuardianRelationshipEnum
MOTHER · FATHER · GRANDPARENT · SIBLING · UNCLE · AUNT · FAMILY_FRIEND · OTHER
ChildCheckInStatusEnum
CHECKED_IN · CHECKED_OUT · FLAGGED
ReminderIntervalPresetEnum
15m (15 min) · 30m (30 min) · 1h (1 hour) · 3h (3 hours) · 24h (24 hours) · 48h (48 hours)
TitheBatchStatus
PENDING · PROCESSING · COMPLETED · FAILED
TitheUnmatchedStatus
PENDING · MATCHED · DISMISSED
TitheDisputeStatus
PENDING · CONFIRMED_VALID · REJECTED
FinanceRequestStatus
PENDING · APPROVED · REJECTED
FirstTimerSourceEnum
WALK_IN · ONLINE · REFERRAL
FollowUpTaskTypeEnum
FIRST_TIMER · ONLINE_NO_RESPONSE · MANUAL
FollowUpTaskStatusEnum
PENDING · IN_PROGRESS · COMPLETED · UNREACHABLE
FollowUpOutcomeEnum
JOINED · DECLINED · NO_ANSWER · PRAYED_WITH
ServiceProgrammeStatusEnum
DRAFT · LIVE · COMPLETED
ServiceSlotTypeEnum
SPEAKER · WORSHIP · PRAYER · OFFERING · ANNOUNCEMENT · BREAK
ServiceSessionStatusEnum
LIVE · COMPLETED
ServiceSessionSlotStatusEnum
PENDING · IN_PROGRESS · COMPLETED · SKIPPED
ServicePauseReasonEnum
TECHNICAL_ISSUE · ANNOUNCEMENT · BREAK_INTERVAL · UNPLANNED_DELAY · OTHER
ServiceActionRoleEnum
ADMIN · WORKER · PUBLIC_LINK (action performed via the public Programme Manager share link, no authenticated member)
IncidentStatusEnum
OPEN · IN_PROGRESS · RESOLVED
AssetStatusEnum
ACTIVE · INACTIVE · UNDER_MAINTENANCE · DECOMMISSIONED
MaintenanceFrequencyUnitEnum
DAYS · WEEKS · MONTHS
MaintenanceRecordTypeEnum
SCHEDULED · UNPLANNED
MaintenanceCompletionStatusEnum
IN_PROGRESS · COMPLETED
AssetConditionEnum
GOOD · FAIR · POOR
PrayerDayMode
PHYSICAL · VIRTUAL
PrayerRuleType
ROLE_FREQUENCY · MIN_LEADERS_PER_MEETING · MAX_PER_MEETING
PrayerAssignmentType
FIXED · SELF_SELECTED · AUTO_ASSIGNED
PrayerRosterStatus
SCHEDULED · RESCHEDULED
PrayerMeetingStatus
SCHEDULED · COMPLETED · CANCELLED
PrayerWindowStatus
PENDING · OPEN · CLOSED
RentalMemberCategory
PUBLIC · MEMBER · WORKER · LEADER
Determines which pricing tier is applied. Resolved at booking time: LEADER if a DepartmentLead record exists for the member, WORKER if role = WORKER, otherwise MEMBER.
RentalDiscountType
PERCENTAGE · FLAT
RentalDiscountSource
NONE · TIER · OVERRIDE
Stored as a snapshot on the booking to record how the discount was determined.
RentalBookingStatus
PENDING · CONFIRMED · IN_PROGRESS · COMPLETED · CANCELLED · REJECTED
Transitions: PENDING → CONFIRMED (admin) or CANCELLED/REJECTED; CONFIRMED → IN_PROGRESS (scheduler); IN_PROGRESS → COMPLETED (scheduler).
RentalPaymentType
SERVICE_FEE · CAUTION
RentalPaymentStatus
PENDING · PAID · REFUNDED (REFUNDED only valid for CAUTION payments)
LivePlatformEnum
YOUTUBE · MIXLR
Used by the Sermon Module’s manual “Announce Live” trigger to pick the default announcement title.
GameStatusEnum
DRAFT · LIVE_SESSION_ACTIVE · ARCHIVED (not yet reachable via any endpoint)
Informational, not a gate — a DRAFT game can always be edited even if LIVE_SESSION_ACTIVE from a past session
that hasn’t been ended yet.
GameSessionStatusEnum
SCHEDULED (not yet reachable — sessions start directly into LIVE) · LIVE · ENDED
ReminderSettingKey
pledge_reminder · budget_alert · follow_up_stale · asset_maintenance · asset_warranty · vehicle_expiry ·
assignment_due · class_session
See “Reminder Settings Module” above — keys a tenant’s per-category reminder timing/enabled settings
(GET/PATCH /admin/reminder-settings). Distinct from EmailCategory (a separate, coarser enum — see below).
EmailCategory
ATTENDANCE_CHECKIN · BIRTHDAY · EVENT_REMINDER · PRAYER_REMINDER · FOLLOW_UP · ASSET_ALERTS ·
GIVING_RECEIPT · FINANCE_ALERTS · SESSION_REPORT · INCIDENT_REPORT · CHILDREN_CHURCH · LOGIN_ALERT ·
SERVICE_PROGRAMME_ASSIGNMENT · PASTOR_FEEDBACK · MEMBERSHIP_ANNIVERSARY · ASSIGNMENT_REMINDER ·
CLASS_SESSION_REMINDER
See “Email Category Settings Module” above — every category-tagged email checks a global env-flag gate, then a
per-tenant EmailCategorySettingsService gate, before EmailQueueService.queueEmail enqueues the job
(GET/PATCH /admin/email-category-settings). Emails sent with no category (OTP, password reset, account-locked —
security-critical auth flows) always send regardless, by design.
Discuva — Technical Documentation
Table of Contents
- System Overview
- Architecture
- Data Models
- Authentication & Authorization
- Module Reference
- API Endpoints Quick Reference
- Check-In Flow
- Automated Absence Marking
- Role & Permission Matrix
- Environment Variables
- Enum Reference
1. System Overview
A NestJS REST API that manages church membership, service attendance, workforce scheduling, class enrolment, Sunday School sessions, Children Church security check-in, internal announcements, tithe records, internal finance requests, and prayer meeting roster management for a local church.
Core design principles:
- Every church member has one account. Workers are members with an optional
WorkerProfileattached. - A single JWT login endpoint serves all member roles: MEMBER and WORKER. Admin portal access is controlled separately
via the
adminstable. - There are two distinct frontends: a mobile app for members and workers, and an admin web portal managed via the Admin RBAC system.
- Attendance is tracked per Event, not per slot. One event can have multiple slots but each member gets exactly one attendance record per event.
- Members are PRESENT or ABSENT. Workers can also be LATE (arrived after threshold) or ON_LEAVE (approved leave covering the event date). ON_LEAVE is neutral — it neither contributes to nor breaks the attendance streak.
- Absentees are marked automatically by a background cron job, not by user action.
- Sunday School tracks session-based attendance for permanent class assignments. Both teachers and enrolled students can mark attendance; self-mark requires an open window set by staff.
- Children Church provides a full security check-in/check-out system for 1000+ children, with per-session 6-character pickup codes, multiple guardians per child, automatic age-group assignment by date of birth, and pickup email notifications to guardians.
- Follow-Up tracks first-time visitors and online non-responders. A FollowUpTask is auto-created on every first-timer registration and assigned to a FOLLOW_UP-department worker via round-robin (fewest open tasks wins). After every event, thank-you emails are sent to all attendees and online-confirm requests are sent to absent members if
onlineAttendanceEnabledis set. Members who don’t confirm online attendance withinONLINE_CHECKIN_WINDOW_HOURSget a follow-up task.
2. Architecture
src/
├── auth/ Single login, JWT strategy, refresh tokens
├── member/ Universal identity: Member + WorkerProfile
├── event/ Event + ServiceSlot + EventConfig
├── venue/ Named, reusable venue entities (lat/lon)
├── attendance/ Check-in, history, leaderboard, cron job
├── department/ Departments + leads
├── request-leave/ Worker leave requests
├── classes/ ChurchClass + ClassEnrollment
├── announcement/ Announcements with audience targeting
├── birthday/ Birthday greetings, wish wall (BirthdayWish entity)
├── notes/ Pastoral notes (naming, dedication, marriage)
├── dashboard/ Aggregated dashboards per role
├── sunday-school/ Session-based SS classes, members, sessions, attendance
├── children-church/ Age groups, class groups, child profiles, guardians, check-in/out
├── admin/ Admin RBAC: AdminRole + Admin entities, AdminGuard, seed (@Global module)
├── tithe/ Batch tithe upload (Excel), queue-based processing, dispute resolution, member PDF statements
├── finance-request/ Department expense requests lifecycle (submit → approve/reject → proof)
├── follow-up/ First-timer registration, follow-up task management, post-event email jobs, online attendance
├── service-programme/ Service programme authoring, live session control, analytics, PDF reports
├── service-headcount/ Physical attendance headcounts per service slot, trends by period
├── prayer/ Prayer meeting roster: schedule config, day configs, rules, self-selection, auto-assignment, reminders
└── utility/ Email queue, cache, hashing, pagination, email delivery log, Cloudinary file uploads, PDF generation
Stack: NestJS · TypeORM · PostgreSQL · Redis · Bull · ioredis · Argon2 · Passport (JWT + Local) · class-validator · nestjs-schedule · @nestjs/throttler · Handlebars · DOMPurify · ExcelJS · PDFKit · Cloudinary · Nodemailer (Gmail SMTP) · Resend SDK · libphonenumber-js
3. Data Models
Member
The universal identity for every person in the system.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| firstname, lastname | string | |
| string | Unique | |
| password | string | Argon2 hashed |
| changedPassword | boolean | false on signup and admin password reset; set to true after first change |
| deviceId | string | null | Mobile device fingerprint registered on first login; null until first mobile login or after admin purge |
| role | MemberRoleEnum | MEMBER | WORKER (no ADMIN role — admin access is a separate entity) |
| status | MemberStatusEnum | ACTIVE | INACTIVE |
| gender | GenderEnum | Optional |
| birthDay | smallint | null | Day of birth (1–31); optional |
| birthMonth | smallint | null | Month of birth (1–12); optional |
| birthYear | smallint | null | Year of birth (1900–2100); optional — may be omitted when unknown |
| maritalStatus | MaritalStatusEnum | Optional |
| yearBornAgain | Date | Stored as Jan 1 of given year |
| yearBaptized | Date | Optional |
| baptizedWithHolyGhost | boolean | Optional |
| dateJoinedChurch | Date (date only) | Optional; full YYYY-MM-DD date, stored in date_joined_church column |
| serveInterestAt | Date | null | When the member asked to serve in the workforce (POST /members/me/serve-interest, or joinWorkforce: true at signup); cleared on withdraw, admin dismissal, promotion to worker, or deactivation. Tenant migration AddMemberServeInterest |
| photoUrl | string | null | Cloudinary secure_url of the member’s self-uploaded profile picture. null until first upload. |
| photoPublicId | string | null | Internal — Cloudinary public_id, used to delete the old asset on replace/remove. Not exposed on MemberDto. |
| workerProfile | WorkerProfile | OneToOne, null for plain members |
| clergy | Clergy | null | OneToOne, null unless the member carries a clergy designation — see Clergy table below |
| attendances | Attendance[] | OneToMany |
| enrollments | ClassEnrollment[] | OneToMany |
Profile picture: self-service via POST/DELETE members/me/photo (JwtAuthGuard), uploaded to Cloudinary folder profile-pictures (3MB limit, image mimetypes only). Replacing a photo uploads the new one first, saves it, then deletes the previous Cloudinary asset by photoPublicId (fire-and-forget). Admins can also clear a member’s photo via DELETE members/:id/photo (AdminGuard + MEMBERS_WRITE) for moderation. GET /birthday/today’s BirthdayCelebrant shape also carries photoUrl, alongside the existing role/department/clergyTitleName disambiguation for same-named celebrants (see Birthday Module).
WorkerProfile
Created when a member is promoted to WORKER. Never deleted by any revocation path — revokeWorker and demoteTraineeToMember both deactivate the row (status = INACTIVE) rather than removing it, so a member’s worker history (department, profession, completedSOD/completedBibleCollege, isTrainee) survives and is picked back up if they’re later re-promoted.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| member | Member | OneToOne |
| department | Department | ManyToOne — primary department |
| secondaryDepartment | Department | null | ManyToOne, nullable — secondary department; HOD/D-HOD can be assigned from primary OR secondary department |
| status | WorkerStatusEnum | ACTIVE | INACTIVE |
| profession | string | Optional |
| yearJoinedWorkforce | Date | Optional |
| completedSOD | boolean | School of Disciples |
| completedBibleCollege | boolean | |
| isTrainee | boolean | Default false, indexed (IDX_worker_profiles_is_trainee). Marks a worker as still in training/probation — has full worker access (role stays WORKER, RolesGuard only checks role) but is flagged in the UI (mobile “Training” badge, admin “Trainee” badge). Toggled via PATCH members/:id/worker-profile; a real flip (not just the field being present with its existing value) is separately audit-logged as WORKER_TRAINEE_STATUS_CHANGED (metadata: { isTrainee, departmentId }) alongside the always-fired, non-milestone WORKER_PROFILE_UPDATED — this is what lets the digital-footprint timeline show a clean “Started Training”/“Completed Training” entry instead of the generic (and deliberately timeline-excluded) profile-update action. |
Deactivation (revokeWorker / demoteTraineeToMember) — shared, non-destructive: both go through a private deactivateWorkerAccess() helper that removes DepartmentLead rows and any Sunday School teacher assignment (no cascade on those FKs), sets workerProfile.status = INACTIVE, and resets member.role = MEMBER. Access is fully revoked immediately — RolesGuard does an exact match on role alone, so a MEMBER-role account can’t reach worker routes regardless of what its (inactive) WorkerProfile looks like. The two differ only in guard + what they touch on isTrainee:
revokeWorker— any active worker,POST members/:id/revoke-worker. LeavesisTraineeuntouched (so reinstatement resumes exactly as they left off, trainee or not).demoteTraineeToMember—isTrainee = trueprofiles only (400 otherwise — “use revoke-worker instead”),POST members/:id/demote-trainee. Explicitly clearsisTrainee, since ending trainee status is the point of this action.
Both use AdminGuard + MEMBERS_WRITE.
Reinstatement (promoteToWorker): now checks workerProfile?.status === ACTIVE (not mere existence) before rejecting with “already registered as a worker” — a member with an INACTIVE profile is eligible again. buildOrReactivateWorkerProfile() reuses the existing row instead of creating a new one: department and status are always set from the call, but profession/yearJoinedWorkforce are only overwritten if explicitly supplied this time (otherwise the prior values are kept), and completedSOD/completedBibleCollege/isTrainee are never touched by this path at all — they simply carry over. Audit-logged as WORKER_REINSTATED (vs WORKER_PROMOTED for a genuinely new profile) so the trail distinguishes the two. bulkPromoteToWorker uses the same helper and ACTIVE-only guard for consistency.
Login/refresh gating (auth.service.ts): the “worker account suspended” check is scoped to member.role === WORKER — it used to fire whenever any workerProfile existed with a non-ACTIVE status, which would have wrongly blocked login for a plain MEMBER carrying a leftover INACTIVE profile from a prior revoke/demotion. Applied identically in validateMember, validateRefreshToken, and its rotated-token-replay path.
Clergy
A clergy designation on a member (renamed from “Pastor” 2026-08 — the container was still called “Pastor”
everywhere even though the whole point of the title catalog is that a tenant isn’t locked into Pentecostal/
Protestant terms; “Clergy” is the denomination-neutral standard term for ordained/formal ministry office).
Independent of WorkerProfile/Department — a clergy member may have no department (e.g. a Lead Pastor) or may
separately also be an HOD. At most one row per member (OneToOne on member).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| member | Member | OneToOne, onDelete: CASCADE |
| title | ClergyTitle | ManyToOne, onDelete: RESTRICT — see ClergyTitle below |
| canReviewFeedback | boolean | Default true. Independent of title — holding a title (a promotion/recognition) does NOT by itself grant the ability to see and respond to every department’s Pastor Feedback reports. Set explicitly via PATCH /members/:id/clergy/review-access, never as a side effect of a title change. See Pastor Feedback Module below. |
Managed via POST/PATCH/DELETE /members/:id/clergy (see Member Module). Surfaced on MemberDto as
clergy: { title: {id, name}, canReviewFeedback: boolean } | null, computed from the clergy relation.
Legacy type column, finally dropped (DropLegacyClergyType1798268400000). The original pastors table
(pre-ClergyTitle) had type character varying NOT NULL — a closed 3-value enum (LEAD/PARISH/ASSOCIATE).
AddClergyTitles1792382400000 replaced it with the clergy_title_id FK and backfilled type, but its own
comment deferred actually dropping the column to “a later, separate migration once the new code has baked with
no incidents” — that migration was never written. The Clergy entity had already dropped type as a property
entirely, so MemberService.assignClergy’s insert ({member, title}, no type) had no way to know the column
still existed — every new clergy assignment failed in production with null value in column "type" ... violates not-null constraint, since the column had no default. Confirmed no code anywhere (backend or either frontend)
still reads or writes clergy.type before dropping it for real.
ClergyTitle
Tenant-configurable clergy title catalog (added 2026-08, replacing the old hardcoded PastorTypeEnum). A tenant
defines its own titles instead of being locked into Pentecostal/Protestant terms like “Lead Pastor” — a Catholic
tenant can use Priest/Bishop/Deacon, a Methodist tenant Minister/Elder/District Superintendent, etc. Every
existing tenant was seeded with the 3 legacy labels (“Lead Pastor”/“Parish Pastor”/“Associate Pastor”) at migration
time so nothing broke on rollout; tenants are free to rename/delete/add from there.
| Field | Notes |
|---|---|
| id | UUID PK |
| name | Unique, max 40 characters |
| description | Nullable |
| clergy | OneToMany → Clergy |
Same CRUD shape as Department (src/clergy-title/, mirrors src/department/ structurally): create/update
enforce name uniqueness, delete is blocked (400) if any Clergy row still references the title — the DB-level
backstop is clergy.clergy_title_id’s onDelete: RESTRICT. GET /clergy-titles/GET /clergy-titles/:id are
public (mirrors GET /departments); POST/PATCH/DELETE reuse AdminGuard + MEMBERS_WRITE rather than a new
permission pair — same precedent already established for /members/:id/clergy itself.
name’s 40-char cap (added 2026-08) exists to keep the title from distorting the small badge UI it renders in
(the member detail panel and the member’s own account-page header, both flex-wrap pill rows) — not an arbitrary
DB constraint. Enforced via @MaxLength(40) on CreateClergyTitleDto. UpdateClergyTitleDto is a real class
extending PartialType(CreateClergyTitleDto), not the type X = Partial<Y> alias pattern used elsewhere in this
codebase — the latter compiles to Object for reflection purposes, so main.ts’s global ValidationPipe silently
skips validating it entirely (confirmed by testing: no whitelist stripping, no @MaxLength enforcement) since it
can’t resolve a real class to instantiate against. PartialType was needed here specifically so PATCH /clergy-titles/:id enforces the same cap as POST does, not just create.
Routes (src/clergy-title/controller/clergy-title.controller.ts):
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /clergy-titles | Public | Full catalog, ordered by createdAt DESC |
| GET | /clergy-titles/:id | Public | Single title |
| POST | /clergy-titles | AdminGuard (MEMBERS_WRITE) | { name, description? }; 400 if name already exists |
| PATCH | /clergy-titles/:id | AdminGuard (MEMBERS_WRITE) | Partial update of the same fields |
| DELETE | /clergy-titles/:id | AdminGuard (MEMBERS_WRITE) | 400 if any clergy member is still assigned to it |
MemberImportJob
Tracks a single bulk-import spreadsheet upload from preview through commit.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| originalFilename | string | Filename as uploaded |
| status | MemberImportJobStatus | READY_FOR_REVIEW | COMMITTED |
| totalRows | int | Total data rows parsed from the sheet |
| validRows | int | Rows with zero validation errors at preview time |
| createdCount | int | Members actually created on commit |
| failedCommitCount | int | Rows that still failed at commit time despite passing preview |
| createdBy | Admin | ManyToOne, onDelete: RESTRICT |
MemberImportRow
One row of a MemberImportJob’s source spreadsheet.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| job | MemberImportJob | ManyToOne, onDelete: CASCADE |
| rowNumber | int | 1-based spreadsheet row number (header is row 1) |
| data | jsonb | Parsed row fields — see MemberImportRowData interface |
| errors | jsonb (string[]) | Validation errors found at preview time; empty array = eligible to commit |
| status | MemberImportRowStatus | PENDING | CREATED | FAILED |
| createdMemberId | UUID | null | Set once the row’s member is created |
| commitError | string | null | Set only if the row passed preview validation but still failed at commit time |
Event
A church gathering on a specific date.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | string | |
| description | string | Optional |
| eventDate | Date (date only) | Derived, not user-entered: the earliest serviceSlots[].startTime (UTC date). Recomputed whenever slots are (re)created via EventService. Date-level only — use for date-range filtering, not “is this event over” checks. |
| endDate | Date (date only) | Derived: the latest serviceSlots[].endTime (UTC date). Recomputed alongside eventDate. Same date-only caveat as eventDate. |
| startTime | timestamptz | Derived: the precise instant of the earliest slot’s startTime (not truncated). Recomputed alongside eventDate. |
| endTime | timestamptz | Derived: the precise instant of the latest slot’s endTime (not truncated). Use this — not endDate — for “is this event past/live” checks, since endDate only has day-level granularity. |
| attendanceMarked | boolean | Set to true by the cron job after absence records are created. Guards against double-processing. |
| onlineAttendanceEnabled | boolean | Default false. When true, absent members receive an online-confirm email after the event ends. |
| onlineNotificationSentAt | timestamptz | null | Set when the online-confirm emails are dispatched. Used to calculate the confirmation window. |
| onlineConfirmClosesAt | timestamptz | null | When members can no longer confirm online attendance; fixed when the online-confirm emails go out. |
| thankYouSentAt | timestamptz | null | Set after thank-you emails are queued for the event; guards against resending on re-trigger. |
| recurringEventId | UUID | Groups events in a recurring series; for series created since EventSeries exists, this is event_series.id |
| seriesOccurrenceDate | date | null | Church-local date this occurrence stands for in its series; unique per series (UQ_events_series_occurrence) |
| audience | string | EVERYONE (default) | WORKERS | GROUP — who the event is for (see Event audience) |
| audienceGroupId | UUID | null | FK groups (SET NULL), GROUP only; a deleted group makes the event open to everyone |
| serviceSlots | ServiceSlot[] | OneToMany — at least one slot is required at creation |
| attendances | Attendance[] | OneToMany |
EventSeries
The repeat rule behind a recurring event (tenant table event_series).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK; occurrences carry it as events.recurring_event_id |
| name / description | string | Copied onto each new occurrence |
| onlineAttendanceEnabled | boolean | |
| recurrencePattern | string | daily | weekly | monthly |
| recurrenceInterval | int | Every N units |
| startDate | date | First occurrence (church-local) |
| endDate | date | null | null = ongoing |
| slotBlueprint | jsonb | SlotBlueprint[] (times of day + durations) |
| autoProgramme | boolean | Default true; prepare draft programmes from templates |
| generatedThrough | date | null | Last occurrence date created; generation never goes back behind it |
| isActive | boolean | Partial index IDX_event_series_active on generated_through WHERE is_active |
| createdBy | Member | null | SET NULL on delete |
EventTemplate
A saved service type (tenant table event_templates).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | string | Unique case-insensitively (LOWER(name) unique index) |
| description | string | null | |
| onlineAttendanceEnabled | boolean | |
| slotBlueprint | jsonb | SlotBlueprint[] |
| defaultRecurrence | jsonb | null | { recurrencePattern, recurrenceInterval, ongoing, weekday? } |
| autoProgramme | boolean | Default true |
Venue
A named, reusable physical location. Referenced by EventConfig.defaultVenue and optionally overridden per slot via
ServiceSlot.venueOverride; also optionally referenced by SmallGroup.venue (see Small Group Module).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | string | Unique |
| address | string | Optional |
| latitude | float | WGS84 latitude |
| longitude | float | WGS84 longitude |
Deleting a venue that is set as defaultVenue on any EventConfig is rejected by the DB FK constraint. Deleting a
venue that is a slot-level venueOverride sets that field to null (SET NULL).
ServiceSlot
The actual check-in target within an event. One event can have multiple slots.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| event | Event | ManyToOne |
| name | string | Default: “Service” |
| startTime | timestamptz | |
| endTime | timestamptz | |
| config | EventConfig | ManyToOne, nullable |
| venueOverride | Venue | null | ManyToOne, nullable — overrides config.defaultVenue for this slot |
| formatOverride | MeetingFormatEnum | null | Nullable — overrides config.defaultFormat for this slot (IN_PERSON | ONLINE) |
| *Override columns | int | Per-slot overrides that take priority over EventConfig |
Override columns: workerCheckinStartOverride, workerLateOverride, memberCheckinStartOverride,
checkinStopOverride, allowedDistanceOverride; plus checkinCloseModeOverride (string | null — SERVICE_END | AFTER_START, overrides config.checkinCloseMode for this slot)
Resolution (EventService.resolveSlotConfig): format = slot.formatOverride ?? config.defaultFormat;
venue = slot.venueOverride ?? config.defaultVenue. Throws 400 only when the resolved format is IN_PERSON and
venue is still null — an ONLINE-resolved slot never requires a venue. A slot overriding an ONLINE config back
to IN_PERSON must supply its own venueOverride; enforced at save time (EventService.buildSlotFromDto), not
just at first check-in.
EventConfig
A reusable timing template assigned to service slots. Venue is a first-class relation rather than raw lat/lon.
| Field | Type | Description |
|---|---|---|
| name | string | Unique |
| defaultVenue | Venue | null | ManyToOne, nullable, RESTRICT on delete — required when defaultFormat is IN_PERSON, forbidden when ONLINE (enforced in EventConfigService, not a DB constraint) |
| defaultFormat | MeetingFormatEnum | IN_PERSON | ONLINE. Default IN_PERSON — every pre-existing config keeps its behavior unchanged |
| onlineMeetingUrl | string | null | Optional join link shown to members/workers when the resolved format is ONLINE |
| workerCheckinStartOffsetSeconds | int | Seconds relative to startTime when workers can start checking in. Negative = before start |
| workerLateOffsetSeconds | int | Seconds after startTime after which workers are LATE |
| memberCheckinStartOffsetSeconds | int | When members can start checking in |
| checkinStopOffsetSeconds | int | When check-in closes for everyone (AFTER_START only; never after the service’s end) |
| checkinCloseMode | string | SERVICE_END (closes when each service ends) | AFTER_START (default for existing rows) |
| allowedDistanceInMeters | int | Max distance from the resolved venue for location validation (ignored for ONLINE) |
| autoStartSession | bool | Default false. See ProgrammeAutoStartScheduler (Service Programme section) below |
Constraint: workerLateOffset > workerCheckinStartOffset and checkinStopOffset > workerLateOffset
MeetingFormatEnum (src/utility/enum/meeting-format.enum.ts, shared with SmallGroup): IN_PERSON | ONLINE.
Deliberately two values, no HYBRID.
Check-in behavior for ONLINE slots: AttendanceService.checkin skips the “workers must provide location”
requirement when the resolved slot format is ONLINE, and never runs distance validation against a null venue.
Attendance
One record per member per event. Workers and members both receive one attendance record per event; workers are distinguished by a LATE status if they arrive after the threshold.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| member | Member | ManyToOne, CASCADE on delete |
| event | Event | ManyToOne, CASCADE on delete — the event being attended |
| serviceSlot | ServiceSlot | ManyToOne, nullable, SET NULL on delete — which slot they entered. Indexed. |
| status | AttendanceStatusEnum | PRESENT | LATE | ABSENT | ON_LEAVE | ATTENDED_ONLINE |
| checkinTime | timestamptz | Null for cron-created ABSENT/ON_LEAVE records |
| roleAtCheckin | MemberRoleEnum | Snapshot of role at check-in time |
| location | JSON | {latitude, longitude} or null; mandatory for workers at check-in |
Unique constraint: (member, event) — one record per person per event.
Streak rules:
- PRESENT, LATE, and ATTENDED_ONLINE all count as present and increment the streak.
- ON_LEAVE is neutral — it neither increments nor breaks the streak.
- ABSENT breaks the streak.
Department
| Field | Notes |
|---|---|
| id | UUID PK |
| name | Unique |
| description | |
| capabilities | DepartmentCapability[] (text[], default {}) — fixed, code-defined feature flags this department grants to its workers (both primary and secondary). Validated against the DepartmentCapability enum (src/department/enums/department-capability.enum.ts) — unlike the key column it replaced, a capability only exists if a real feature is gated on it, and a single department can hold more than one. |
| workerProfiles | OneToMany → WorkerProfile |
DepartmentLead
Joins a WorkerProfile to a Department as head or assistant lead.
PastorFeedback
Weekly structured feedback a department’s HOD or Assistant HOD (D_HOD) submits, which a pastor can read and respond to — from both the admin portal and the mobile app.
| Field | Notes |
|---|---|
| department | ManyToOne → Department (onDelete: CASCADE — historical feedback for a deleted department is meaningless to retain) |
| submittedBy | ManyToOne → WorkerProfile, nullable (onDelete: SET NULL — a later worker revocation shouldn’t be blocked by old feedback) |
| submittedByName | Snapshotted at submit time (mirrors AuditLog’s targetName pattern) so history survives regardless of the live FK |
| weekOf | date — the Monday of the week being reported on (canonical anchor, unambiguous) |
| attendanceNotes | text, required |
| highlights | text, required |
| challenges | text, required |
| prayerRequests | text, nullable |
| additionalNotes | text, nullable |
| submittedAt | auto timestamp |
| respondedByClergy | ManyToOne → Clergy, nullable (onDelete: SET NULL) |
| respondedByClergyName | Snapshotted at response time, same rationale as submittedByName |
| pastorResponse | text, nullable |
| pastorRespondedAt | timestamp, nullable |
Unique constraint: (department, weekOf) — one submission per department per week. Editing after submission is a PATCH on the same row; there’s no draft/submitted status or read-receipt lock.
Ownership check (submission/edit): the caller must be an HOD or Assistant HOD (DepartmentLead row) of the target department — checked via DepartmentLead.exists({ workerProfile, department }), mirroring the isHod check in auth.service.ts:getProfile(). Not gated by RolesGuard/@Roles(WORKER) alone, since being a worker isn’t sufficient — must specifically lead that department.
Ownership check (feedback response, added 2026-08 — now stricter than mere Clergy existence): the caller must have a Clergy record with canReviewFeedback: true (PastorFeedbackService.assertCanReviewFeedback()), not just any clergy designation regardless of title. This is deliberately decoupled from title — being promoted to a new title (or holding any title at all) does not by itself grant the ability to see and respond to every department’s reports; an admin grants that separately via PATCH /members/:id/clergy/review-access, defaulting true for existing clergy so nothing broke on rollout. Available via both the admin portal (an Admin account whose linked Member has such a Clergy record) and the mobile app.
PrayerRequest
A private prayer request submitted by any member/worker — visible only to the submitter, Prayer department workers, and clergy.
| Field | Notes |
|---|---|
| member | ManyToOne → Member, nullable (onDelete: SET NULL — a deactivated member’s request history survives) |
| submittedByName | Snapshotted at submit time, same rationale as PastorFeedback.submittedByName |
| content | text, required |
| status | OPEN | PRAYED_FOR | ANSWERED (character varying, default OPEN) |
Testimony
An opt-in-public testimony — either tied to one of the submitter’s own prayer requests, or general.
| Field | Notes |
|---|---|
| member | ManyToOne → Member, nullable (onDelete: SET NULL) |
| submittedByName | Snapshotted at submit time |
| prayerRequest | ManyToOne → PrayerRequest, nullable (onDelete: SET NULL) — null means a general testimony |
| content | text, required |
| isPublic | boolean, default false — the submitter’s own opt-in flag set at submission time; no separate publish/moderation step |
PregnancyPrayerCase
Tracks a pregnant woman receiving ongoing prayer support — created and managed by the Prayer team (or pastors), not self-submitted. Lives in the same src/prayer-request/ module and reuses PRAYER_READ/PRAYER_WRITE — no new permission.
| Field | Notes |
|---|---|
| member | ManyToOne → Member, nullable (onDelete: SET NULL) — she may not be an existing member |
| name | Snapshot, always present regardless of member |
| edd | date — estimated due date |
| details | text, nullable — general context/notes |
| status | ACTIVE | DELIVERED | DISCONTINUED (character varying, default ACTIVE) |
| lastPrayedAt | timestamptz, nullable — denormalized, updated whenever a new PregnancyPrayerVisit is logged |
| createdBy | ManyToOne → Member, nullable (onDelete: SET NULL) |
| createdByName | Snapshotted at creation time |
PregnancyPrayerVisit
A log entry recorded each time the Prayer team prays with/visits a pregnant woman — mirrors the FirstTimerVisit idiom in the Follow-Up module.
| Field | Notes |
|---|---|
| case | ManyToOne → PregnancyPrayerCase (onDelete: CASCADE) |
| loggedBy | ManyToOne → Member, nullable (onDelete: SET NULL) |
| loggedByName | Snapshotted at log time |
| note | text, nullable — follow-up note |
| visitedAt | timestamptz, default now |
RequestLeave
| Field | Notes |
|---|---|
| workerProfile | ManyToOne → WorkerProfile |
| dateFrom / dateTo | date (YYYY-MM-DD, no time component) |
| reason | string |
| status | PENDING | APPROVED | REJECTED |
| actionedBy | ManyToOne → Member (admin who approved/rejected) |
ChurchClass
| Field | Notes |
|---|---|
| classType | ManyToOne → ClassType (nullable: false, onDelete: RESTRICT) |
| startDate / endDate | date strings |
| nextSessionAt | timestamptz, nullable — the next session time used by session reminders. Once a class has ClassSession rows it is kept in step automatically (next upcoming session); without sessions it’s still set by hand via PATCH /classes/:id/session |
| meetingLink | varchar, nullable — join link shown alongside nextSessionAt; synced from the next session when that session has its own link |
| minAttendancePercent | int 1–100, nullable — completion rule (null = no attendance rule) |
| requireAllAssignments | boolean, default false — completion rule: every published assignment submitted |
| openForRequests | boolean, default false — members can ask to join from the app |
| capacity | int, nullable — max people IN_PROGRESS; requests/approvals are refused when full |
| materials | OneToMany → ClassMaterial, cascade: true — see below |
| facilitators | OneToMany → ClassFacilitator, cascade: true — see below |
Delete guard: Deleting a class is blocked if any enrolment record exists (any status — IN_PROGRESS, COMPLETED, or CANCELLED). This preserves historical enrolment data. A class with enrolment history cannot be deleted. Deleting an allowed (enrolment-free) class also cleans up its materials’ Cloudinary assets first (see ClassMaterial below) — the FK’s onDelete: CASCADE removes the class_materials rows automatically, but nothing app-side fires on a DB-level cascade, so this cleanup has to happen explicitly before the class row is removed. class_facilitators rows cascade-delete too, but need no app-side cleanup — there’s no external asset attached to a facilitator row.
ClassFacilitator
Replaces the old ChurchClass.facilitator (a single Member FK). A class can have several facilitators, and not every facilitator is a registered Member — an outside guest speaker is named via free text instead.
| Field | Notes |
|---|---|
| churchClass | ManyToOne → ChurchClass (nullable: false, onDelete: CASCADE) |
| member | ManyToOne → Member, nullable (nullable: true, onDelete: SET NULL) |
| guestName | varchar, nullable |
| order | int, default 0 — display order |
Exactly one of member/guestName is set per row — validated in ClassesService (not the DTO, since class-validator can’t cleanly express “exactly one of two fields”); an entry with both or neither throws BadRequestException.
A class must always have at least one facilitator. CreateChurchClassDto.facilitators is a required, non-empty array (@ArrayMinSize(1)). UpdateChurchClassDto.facilitators is optional — omit it to leave the existing facilitators untouched — but if provided, it must also be non-empty and replaces the full list (no incremental add/remove endpoints, unlike ClassMaterial; a facilitator has no upload step or cross-class reuse concern to preserve).
Request shape for both create and update: facilitators: [{ memberId?: string, guestName?: string }].
Next session (nextSessionAt/meetingLink): PATCH classes/:id/session (body: UpdateClassSessionDto — both fields optional, either can be set to null to clear it) lets a facilitator/admin record when the class next meets and how to join. Deliberately a single mutable pair of columns rather than a ClassSession entity — a class is expected to have one upcoming session in view at a time, updated in place as it progresses, not a pre-populated calendar. Feeds ClassSessionReminderScheduler (see Reminder Settings Module) and is surfaced on both the authenticated member class-detail view and the guest portal (GET classes/guest/:enrollmentId).
ClassMaterial
Replaces the old ChurchClass.documentUrl (a single free-text URL — the previous uploadMaterial() also never persisted Cloudinary’s publicId, so nothing could ever be deleted). One-to-many, so a class can carry multiple titled documents and links, each independently addable/removable.
| Field | Notes |
|---|---|
| churchClass | ManyToOne → ChurchClass (nullable: false, onDelete: CASCADE) |
| title | required — defaults to the uploaded file’s name (extension stripped) when omitted on upload |
| url | the Cloudinary secure URL (upload) or the pasted external link |
| publicId | nullable — Cloudinary asset id; null for a pasted link (nothing to delete from Cloudinary) |
| resourceType | nullable — Cloudinary resource type (image/video/raw); null for a pasted link |
| mimeType | nullable — set for uploads only |
| sizeBytes | bigint, nullable — set for uploads only |
| order | int, default 0 — display order, assigned incrementally as materials are added |
Three ways to add a material (all class-scoped, AdminGuard + CLASSES_WRITE):
POST classes/:id/materials/upload— multipart, fieldfile(+ optionaltitlefield), same file-type allowlist as before (PDF/Word/PowerPoint/image), size gated byPlatformSettingKey.MAX_CLASS_MATERIAL_UPLOAD_MBviaDynamicLimitedFileInterceptor. Uploads to theclass-materialsCloudinary folder and creates the row in one call.POST classes/:id/materials/link— JSON{ title, url }, no Cloudinary asset (publicId: null).POST classes/:id/materials/reuse— JSON echoing aGET classes/materials/libraryentry’s fields back; creates a new row pointing at the same Cloudinary asset (or the same pasted URL) without a new upload.
Reference-counted deletion (DELETE classes/:id/materials/:materialId): because “reuse” lets multiple ClassMaterial rows share one publicId, deleting a row only calls CloudinaryService.deleteByPublicId() if no other row still references that publicId — checked via a live exists() query at delete time (not a stored counter, so it can never drift out of sync). A pasted-link row (publicId: null) never touches Cloudinary at all. The same check runs for every material when a class itself is deleted.
Indexes: church_class_id (FK, backs the materials relation join and the pre-delete cleanup loop’s per-class fetch) and public_id (backs the reference-counted exists() check above, which runs on every material/class deletion).
Library (GET classes/materials/library, CLASSES_READ): dedups every material across every class by publicId (uploads) or url (pasted links with no publicId), returning { title, url, publicId, resourceType, mimeType, sizeBytes, usedByClassNames }[] — lets the admin UI’s “Reuse Previous” picker show what’s already in use and by which classes, instead of re-uploading the same syllabus for every cohort.
Visibility: GET classes/:id (admin) and the guest portal (GET classes/guest/:enrollmentId) both return materials sorted by order; the guest portal’s hand-built response only exposes {id, title, url, resourceType} per material, not the full row.
ClassType
Replaces the old hardcoded ChurchClassTypeEnum — class types are now admin-creatable and admin-editable, not a fixed set. ChurchClassTypeEnum still exists in code purely as a reference for the migration’s seed data; it’s not used at runtime anymore (removed from the generic /enums endpoint’s churchClassTypes key for the same reason — it no longer reflects reality once admins add their own types).
| Field | Notes |
|---|---|
| name | unique |
| description | nullable text |
| isActive | boolean, default true — deactivated types are hidden from class-create pickers but existing classes keep referencing them (RESTRICT prevents hard-deleting a type still in use) |
| nextClassType | ManyToOne → ClassType, nullable, self-referencing (onDelete: SET NULL) |
Promotion chain: nextClassType is a self-referencing pointer, not a level number — a class type either points to the next type in its progression or is null (standalone, no promotion). The chain is entirely admin-configured via the ClassType CRUD endpoints; nothing is pre-wired by the migration (the 5 seeded legacy types — Believers’ Class, Baptismal Class, Workers in Training, Bible College, School of Discipleship — all seed with nextClassType = null). Writes are validated server-side against self-reference and cycles (walks the proposed chain up to 20 hops looking for a loop back to the type being edited) since a DB FK can’t express “no cycles.”
Seeded ids re-keyed (migration 1799823600000-ReplaceSeededClassTypeIds): the genesis seed used ids like
11111111-0000-0000-0000-000000000001, which aren’t RFC 4122 UUIDs, so @IsUUID() and ParseUUIDPipe rejected them
(“classTypeId must be a UUID” when creating a class; editing/deleting a seeded type also failed). The migration gives
those rows gen_random_uuid() ids and repoints church_classes.class_type_id and class_types.next_class_type_id;
custom types are untouched. The class-type list cache key moved to class-types:all:v2 so stale cached ids aren’t served.
Guest
A non-member taking a Training Class — e.g. a visitor’s spouse attending marriage counselling alongside their member partner. Deliberately its own entity, not inline columns on ClassEnrollment: a guest is a repeat, evolving identity that may take several classes over time, so contact details are stored once here and referenced from each enrollment, rather than duplicated (and risking staleness) per enrollment row.
| Field | Notes |
|---|---|
| firstName | required |
| lastName | required |
| required, unique — the primary contact channel, prioritized over phone | |
| phone | nullable — optional, used only if the guest opts into SMS |
| churchName | nullable — their home church, if any |
| address | nullable |
| notes | text, nullable — open catch-all |
| convertedMember | ManyToOne → Member, nullable, onDelete: SET NULL — set once this guest converts to a full Member; kept as a permanent historical link even after ClassEnrollment.member is also updated directly (see conversion below) |
Find-or-create by email: GuestService.findOrCreateByEmail() backs both the “new guest” enrollment form (profile fields provided, no prior record) and the “existing guest” search-and-select path — a returning guest’s contact details are looked up once by email, not re-entered per class.
Conversion (POST classes/guests/:guestId/convert-to-member): admin-only, scoped to the guest record (not one enrollment) — reuses MemberService.createByAdmin(), the same temp-password + forced-change-password + welcome-email flow used for every other admin-created member. Builds a SignupDto from the guest’s firstName/lastName/email/phone, creates the member, sets guest.convertedMember, then bulk-updates every ClassEnrollment referencing that guest to point at the new member directly — so downstream code (reminders, Announcements audience resolution, submissions) never needs to know about the guest→member relationship, only “does this enrollment have a member.” The guest record and its profile data are kept as history, not cleared. Audit-logged as GUEST_CONVERTED_TO_MEMBER.
ClassEnrollment
| Field | Notes |
|---|---|
| member | ManyToOne → Member, nullable |
| guest | ManyToOne → Guest, nullable, indexed, onDelete: SET NULL |
| purpose | text, nullable — why this specific enrollee is taking this specific class; per-enrollment (not per-guest), since the same guest could take two classes for two different reasons |
| churchClass | ManyToOne → ChurchClass |
| status | IN_PROGRESS | COMPLETED | CANCELLED |
| enrolledAt | auto timestamp |
| completedAt / cancelledAt | set when status changes |
| certificateIssued | boolean, default false |
| certificateIssuedAt | timestamptz, nullable |
| certificateNumber | varchar, nullable |
Exactly one of member/guest is required — DB-level CHECK constraint member_id IS NOT NULL OR guest_id IS NOT NULL. Not exclusive (not XOR): a converted guest ends up with both set — guest is retained as history, member is set at conversion time (see Guest above).
Unique constraints: (member, churchClass) and (guest, churchClass) — a member and a guest can each only have one enrollment record per class.
Guest portal access: a guest never logs in — their own ClassEnrollment.id (already a random-looking UUID) is the access key, mirroring the Forms module’s public-submission pattern (@Public(), no token-generation system). On enrollment, class-guest-access emails a link to GET classes/guest/:enrollmentId (frontend: discuva-member’s app/classes/guest/[id]/page.tsx, no Shell/withAuth). That route + the paired POST classes/guest/:enrollmentId/assignments/:assignmentId/submit are the only surface a guest can reach — no other member-app feature, no self-serve conversion.
Level promotion: When an enrollment is COMPLETED and its class’s classType.nextClassType is set, GET classes/enrollments/:enrollmentId/promotion-candidate reports eligibility plus any currently-open (ACTIVE) classes of that next type. Promotion itself is a separate, explicit, admin-confirmed action — POST classes/enrollments/:enrollmentId/promote (body: targetClassId) — mirroring the promoteToWorker pattern (transaction + CLASS_LEVEL_PROMOTED audit log entry + a class-level-promotion templated email to the member). Nothing is auto-enrolled on completion; standalone class types (no nextClassType) simply have no promotion affordance.
Certificates: Once an enrollment is COMPLETED, PATCH classes/enrollments/:enrollmentId/certificate (body: optional certificateNumber) marks it as having received a certificate — sets certificateIssued = true, certificateIssuedAt = now(), and stores certificateNumber if given. This is a manual, admin-confirmed record only (no file upload); it logs CLASS_CERTIFICATE_ISSUED.
Announcement
| Field | Notes |
|---|---|
| audience | ALL | WORKERS_ONLY | MEMBERS_ONLY | DEPARTMENT | INDIVIDUAL | GROUP | CLASS |
| department | ManyToOne → Department (required when audience=DEPARTMENT) |
| targetMember | ManyToOne → Member, nullable (required when audience=INDIVIDUAL) |
| group | ManyToOne → Group, nullable (required when audience=GROUP) |
| churchClass | ManyToOne → ChurchClass, nullable, column class_id (required when audience=CLASS) |
| publishedAt | defaults to creation time |
| expiresAt | nullable; expired items excluded from feed |
| sendViaSms | boolean, default false; requires the caller’s admin role to hold SMS_SEND (see SMS Module) |
| smsBody | text, nullable; required when sendViaSms=true; deliberately separate from body since SMS is billed per segment |
AnnouncementReaction
A member’s emoji reaction to an announcement they received.
| Field | Notes |
|---|---|
| announcement | ManyToOne → Announcement, onDelete: CASCADE, indexed |
| member | ManyToOne → Member, onDelete: CASCADE |
| emoji | character varying, validated against a fixed set (ReactionEmojiEnum: 👍 ❤️ 🙏 🎉 👏) |
Unique constraint: (announcement, member) — one reaction per member per announcement. Reacting again with a different emoji updates the existing row rather than adding a second one (not Slack-style multi-emoji-per-user).
Group
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | string | Unique |
| description | text | null | Optional |
| createdBy | Member | null | ManyToOne, SET NULL on delete. Column is created_by_id (see migration note below). |
| members | GroupMember[] | OneToMany reverse side |
GroupMember
Join entity between Group and Member. @Unique(['group', 'member']) prevents duplicate membership rows.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| group | Group | ManyToOne, CASCADE on delete |
| member | Member | ManyToOne, CASCADE on delete |
| addedBy | Member | null | ManyToOne, SET NULL on delete. Column is added_by_id (see migration note below). |
Migration note: AddGroupsModule originally created these FK columns as created_by/added_by. Neither entity
has an explicit @JoinColumn, so SnakeNamingStrategy.joinColumnName (which names join columns as
<relation>_<referencedColumn>) expects created_by_id/added_by_id at runtime — the mismatch caused
column grp.created_by_id does not exist whenever the relation was selected (e.g. the announcements list with
audience=GROUP). Fixed by migration FixGroupsForeignKeyColumnNames, which renames both columns; per the
immutable-migrations rule the original AddGroupsModule file was left untouched.
BirthdayWish
Persists birthday wishes permanently, grouped by year. The birthday announcement expires but wishes remain readable indefinitely.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| message | text | DOMPurify-sanitized plain text, max 500 chars |
| recipient | Member | ManyToOne, CASCADE on delete |
| sender | Member | null | ManyToOne, SET NULL on delete |
| year | smallint | Calendar year the wish was sent |
Unique constraint: (recipient, sender, year) — one wish per sender per recipient per year.
AdminRole
A named role in the admin RBAC system. Carries a list of permissions.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | string | Unique (e.g. “SuperAdmin”, “ContentManager”) |
| description | string | Optional |
| permissions | AdminPermission[] | simple-array column; subset of AdminPermission enum |
| admins | Admin[] | OneToMany |
Admin
Links a church member to an admin role. This is the portal-access record — it is separate from the member’s church role (MEMBER/WORKER).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| member | Member | OneToOne, CASCADE on delete — the admin must be a church member |
| adminRole | AdminRole | ManyToOne, RESTRICT on delete — deleting a role with active admins is blocked |
| isActive | boolean | Soft-disable without revoking the role |
| favouritePages | string[] (jsonb) | Admin-portal routes the admin pinned to the dashboard’s Quick Access, in their order; max 12. Default []. Tenant migration AddAdminFavouritePages |
Relationship: A church worker can also be an admin. Having role=WORKER on the Member entity and an Admin record
are independent. Mobile app routes check role=WORKER; admin portal routes check the admins table.
Email notifications:
POST /admin/users(grant) — sends awelcome-adminemail to the member containing their email address and the admin portal login URL. Password is not re-generated; the message instructs the user to log in with their existing account password.POST /admin/users/:id/revoke— sends anaccount-deactivatedemail to the affected member.
AuditLog
Immutable record of every admin write action.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| action | AuditAction | String enum — see Audit Actions below |
| actor | Member | null | ManyToOne FK to members.id, SET NULL on member delete — the admin who performed the action |
| targetId | UUID | null | The ID of the affected resource (member, event, department, etc.) |
| targetName | string | null | Human-readable label for the target (a group’s name, an event’s title, a member’s full name — whatever the target actually is) |
| targetEmail | string | null | Email snapshot for identity tracing when targetId alone is insufficient |
| metadata | jsonb | null | Action-specific details (role changed, count of records affected, etc.) |
| createdAt | timestamptz | Auto-set on insert |
Indexes: action, actor (FK column actorId), targetId, createdAt.
Write path: AuditLogService.log() enqueues a job on the audit-log Bull queue (fire-and-forget, 3 attempts, exponential backoff). AuditLogProcessor handles the actual DB write asynchronously. Failed writes are retained in Redis (removeOnFail: false) and visible in Bull Board.
Actor traceability: The actor relation is a real FK to the members table. When building an audit log API, load
the relation (relations: ['actor']) to access actor name and email. If the member account is deleted, actor is set
to null but the log record and all other fields are preserved.
targetName convention: the admin audit-log list view only ever renders targetName ?? targetEmail ?? "—" for
its Target column — it never falls back to displaying the raw targetId, since a bare UUID isn’t meaningful to an
admin reading the trail. Every auditLogService.log(...) call site that sets a targetId should also set
targetName using whatever human-readable field is already in scope on the entity being acted on (a .name/.title
field from an entity already loaded via findOne/getOrThrow just above the call, or a ${firstname} ${lastname} for a person) — this is nearly always free, not a new query. A handful of genuinely nameless entities
(a journal entry, a petty cash request with no notes) fall back to the closest available proxy (a description
field, a formatted date range, free-text notes) rather than a fabricated label; where nothing reasonable exists,
targetName is left unset and the column honestly shows “—”.
EmailLog
Append-only delivery record written by the Bull email processor on every terminal outcome (success or permanent failure). Used for debugging delivery issues and compliance — answers “was this OTP email actually sent?”.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| recipient | string | To address(es), comma-joined if multiple |
| subject | string | Email subject line |
| status | varchar | sent | failed |
| jobId | string | Bull queue job ID — correlate with Redis for in-flight inspection |
| errorMessage | text | SMTP/API error on permanent failure; null on success |
| attemptsMade | int | Number of send attempts before terminal outcome (max 5) |
| provider | varchar | gmail | smtp | resend | sendgrid | mailgun — which email provider delivered (or attempted) the message |
| source | varchar | null | tenant (sent via the church’s own BYOK-configured provider) | platform_default (no tenant provider configured, Discuva’s EMAIL_PROVIDER default was used instead) | null for rows written before this column existed |
| createdAt | timestamptz | When the terminal outcome was recorded |
Written by: @OnQueueCompleted (status = sent) and @OnQueueFailed (status = failed, only on the final
attempt after all retries are exhausted). Transient failures that Bull subsequently retries do not produce a log
row — only the final outcome is recorded.
Provider/source resolution: EmailProcessor.handleSend resolves the actual provider and source (tenant vs
platform_default) before attempting the send, and persists both onto the job’s own data via job.update(). This
is what lets onFailed — which has no return value to read, since a thrown sendMail() call means handleSend
never reaches its return statement — log the provider/source that was actually being attempted rather than
guessing. (Previously onFailed hardcoded the platform default’s name unconditionally, mislabeling any failed send
that was actually attempted through a tenant’s own BYOK provider — fixed by this job.update() persistence.)
Indexes: recipient, status, createdAt.
Audit Actions:
ADMIN_CREATED · MEMBER_SIGNED_UP · MEMBER_LOGIN · MEMBER_LOGOUT · ADMIN_LOGIN · ADMIN_LOGOUT · PASSWORD_CHANGED ·
PASSWORD_RESET_REQUESTED · PASSWORD_RESET_COMPLETED · ADMIN_PASSWORD_RESET · WORKER_PROMOTED ·
WORKER_REVOKED · MEMBER_ACTIVATED · MEMBER_DEACTIVATED · MEMBER_UPDATED · MEMBER_CREATED_BY_ADMIN · MEMBER_PHOTO_UPDATED ·
MEMBER_PHOTO_REMOVED · ATTENDANCE_ADMIN_MARKED ·
PRAYER_REQUEST_SUBMITTED · PRAYER_REQUEST_STATUS_UPDATED · TESTIMONY_SUBMITTED · DEVICE_PURGED ·
DEVICE_RESET_REQUESTED · DEVICE_RESET_COMPLETED ·
ANNOUNCEMENT_CREATED · ANNOUNCEMENT_UPDATED · ANNOUNCEMENT_DELETED · EVENT_CREATED · EVENT_UPDATED ·
EVENT_DELETED · EVENT_SERIES_UPDATED · EVENT_SERIES_STOPPED · EVENT_TEMPLATE_SAVED · EVENT_TEMPLATE_DELETED · NOTE_CREATED · NOTE_UPDATED · NOTE_DELETED · LEAVE_APPROVED · LEAVE_REJECTED ·
DEPARTMENT_CREATED · DEPARTMENT_UPDATED · DEPARTMENT_DELETED · DEPARTMENT_LEAD_ASSIGNED ·
DEPARTMENT_LEAD_REMOVED · WORKER_PROFILE_UPDATED · ADMIN_ROLE_CREATED · ADMIN_ROLE_UPDATED ·
ADMIN_ROLE_DELETED · ADMIN_USER_CREATED · ADMIN_USER_UPDATED · ADMIN_USER_DEACTIVATED
TITHE_BATCH_QUEUED · TITHE_UNMATCHED_RESOLVED · TITHE_UNMATCHED_DISMISSED · TITHE_DISPUTE_APPROVED · TITHE_DISPUTE_REJECTED · TITHE_ACCOUNT_CREATED · TITHE_ACCOUNT_UPDATED ·
FINANCE_CATEGORY_CREATED · FINANCE_CATEGORY_UPDATED · FINANCE_CATEGORY_DELETED · FINANCE_REQUEST_CREATED · FINANCE_REQUEST_APPROVED · FINANCE_REQUEST_REJECTED · FINANCE_PROOF_ATTACHED ·
TITHE_PROOF_SUBMITTED · TITHE_PROOF_CONFIRMED · TITHE_PROOF_DECLINED · TITHE_PROOF_EXPIRED_PURGED ·
CHURCH_SETTING_UPDATED · INCIDENT_REPORT_CREATED · INCIDENT_REPORT_STATUS_UPDATED ·
ASSET_CREATED · ASSET_UPDATED · ASSET_MAINTENANCE_SCHEDULED · ASSET_MAINTENANCE_LOGGED · ASSET_INVENTORY_UPDATED ·
GUEST_CONVERTED_TO_MEMBER
EventReminder
Optional reminder schedule attached to a service slot. Multiple reminders can be configured per slot (one per interval preset).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| serviceSlot | ServiceSlot | ManyToOne, CASCADE on delete |
| audience | AnnouncementAudienceEnum | ALL | WORKERS_ONLY | DEPARTMENT |
| department | Department | null | Required when audience=DEPARTMENT |
| intervalPreset | ReminderIntervalPresetEnum | 15m | 30m | 1h | 3h | 24h | 48h |
| enabled | boolean | Admin can disable without deleting |
| lastSentAt | timestamptz | null | Set when the reminder fires; prevents double-sending |
| fireAt | timestamptz | null | Pre-computed: slot.startTime − preset_minutes. Set on create and on interval preset update; used by the dispatch cron to filter in SQL (no in-memory filtering) |
Unique constraint: (serviceSlot, intervalPreset) — one reminder per preset per slot.
SundaySchoolClass
A permanent Sunday School class. Members are assigned indefinitely (no graduation).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | string | |
| description | string | Optional |
| teacher | Member | null | ManyToOne, nullable — the appointed class teacher |
| assistants | Member[] | ManyToMany via sunday_school_class_assistants (class id, member id; both cascade). Up to 10. Same rights as the teacher for that class |
| ageGroup | string | null | Free text, e.g. “Ages 6–9” (≤60 chars) |
| meetingDay | MeetingDayEnum | null |
SUNDAY…SATURDAY (varchar) |
| meetingTime | string | null | HH:mm, church local time |
| location | string | null | Room or place (≤120 chars) |
Delete guard (class): Blocked if any members are assigned or any sessions have been recorded. Remove all members and sessions before deleting.
Delete guard (session): Blocked if any attendance records exist for the session. Sessions with attendance cannot be deleted — this prevents silent cascade-deletion of historical attendance data.
SundaySchoolMember
Links a church member to a Sunday School class.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| member | Member | ManyToOne |
| sundaySchoolClass | SundaySchoolClass | ManyToOne |
| assignedAt | timestamptz | auto timestamp |
Unique constraint: (member, sundaySchoolClass)
SundaySchoolSession
One session (meeting) of a Sunday School class.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| sundaySchoolClass | SundaySchoolClass | ManyToOne |
| sessionDate | string (YYYY-MM-DD) | Date of the session |
| selfMarkClosesAt | timestamptz | null | Non-null and in the future means the self-mark window is open |
| notes | string | Optional session notes |
Unique constraint: (sundaySchoolClass, sessionDate)
SundaySchoolAttendance
One attendance record per member per session.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| session | SundaySchoolSession | ManyToOne |
| member | Member | ManyToOne |
| status | SundaySchoolAttendanceStatus | PRESENT | ABSENT | EXCUSED |
| markedByTeacher | boolean | True if a teacher/staff marked the record; false if self-marked |
| markedAt | timestamptz |
Unique constraint: (session, member)
SundaySchoolQuestion
A private question a student asked in one of their Sunday School classes, and its answer once one exists. Visible only to the asking member and Sunday School staff/the class’s teacher — never shared with the rest of the class.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| sundaySchoolClass | SundaySchoolClass | ManyToOne |
| askedBy | Member | ManyToOne — the asking student |
| questionText | text | Max 1000 chars (DTO-enforced) |
| answerText | text | null | Max 3000 chars (DTO-enforced); null until answered |
| answeredBy | Member | null | ManyToOne, nullable — the teacher/staff who answered |
| answeredAt | timestamptz | null | null until answered |
Indexes: sundaySchoolClass and askedBy are indexed — every query pattern this module has (per-class list,
per-student list, and the assignment check in askQuestion) filters on one of those two. The cross-class
getAllQuestions/adminGetAllQuestions endpoint is a deliberate exception — an unfiltered ORDER BY created_at DESC
across the whole table — with no index on createdAt: this table is scoped to one tenant’s Sunday School program, so
even years of activity stays small enough (realistically hundreds to low thousands of rows) that an in-memory sort
costs nothing meaningful; add one if that assumption ever stops holding.
ChildAgeGroup
Defines an age bracket for automatic child classification.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | string | e.g. “Nursery”, “Toddlers” |
| minAgeMonths | int | Inclusive lower bound in months |
| maxAgeMonths | int | Inclusive upper bound in months |
| displayOrder | int | UI sort order — lower numbers appear first. Use sequential integers (1, 2, 3…) to control the display order across age brackets. |
Delete guard: Deleting an age group is blocked if any child profiles are directly assigned to it or to any of its
class groups. This prevents silent orphaning — the admin must reassign or remove the affected children first.
Internally, ChildClassGroup rows CASCADE on age-group delete; ChildProfile.ageGroup and ChildProfile.classGroup
are SET NULL on delete.
ChildClassGroup
A physical class room or group within an age group.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | string | e.g. “Nursery Room A” |
| ageGroup | ChildAgeGroup | ManyToOne, CASCADE on delete |
| capacity | int | null | Optional room capacity |
| teacherNote | text | null | Optional notes for the teacher |
Delete guard: Deleting a class group is blocked if any child profiles are currently assigned to it. Reassign children before deleting.
ChildProfile
The central record for a registered child.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| firstname | string | |
| lastname | string | |
| dateOfBirth | string (YYYY-MM-DD) | Used for automatic age-group assignment |
| ageGroup | ChildAgeGroup | ManyToOne — auto-assigned from DOB |
| classGroup | ChildClassGroup | ManyToOne — auto-assigned from age group |
| photoUrl | string | null | Optional |
| specialNotes | string | null | Allergies, medical info, etc. |
| registeredBy | Member | null | ManyToOne, nullable |
| guardians | ChildGuardian[] | OneToMany |
ChildGuardian
A guardian or authorised pickup person for a child.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| child | ChildProfile | ManyToOne |
| fullName | string | |
| relationship | GuardianRelationshipEnum | MOTHER | FATHER | GRANDPARENT | SIBLING | UNCLE | AUNT | FAMILY_FRIEND | OTHER |
| phoneNumber | string | |
| string | null | Direct email; resolved at runtime as guardian.email ?? guardian.member.email |
|
| member | Member | null | ManyToOne, nullable — links guardian to a church member account |
| photoUrl | string | null | Optional |
| isAuthorizedPickup | boolean | Whether this guardian is allowed to pick up the child |
ChildCheckIn
One check-in/check-out record per child per session.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| child | ChildProfile | ManyToOne |
| serviceSlot | ServiceSlot | null | ManyToOne, nullable |
| pickupCode | string (6 chars) | Unique per check-in; sent to guardians via email |
| status | ChildCheckInStatusEnum | CHECKED_IN | CHECKED_OUT | FLAGGED |
| checkinTime | timestamptz | |
| checkoutTime | timestamptz | null | Set on checkout |
| droppedOffBy | ChildGuardian | null | ManyToOne, nullable |
| droppedOffByName | string | Name captured at drop-off |
| pickedUpBy | ChildGuardian | null | ManyToOne, nullable — set on checkout |
| pickedUpByName | string | null | Name captured at pickup |
| checkedInBy | Member | null | ManyToOne, nullable — staff member who performed the check-in |
| flagReason | string | null | Reason if status = FLAGGED |
TitheAccount
Finance-team-managed list of bank accounts members can pay tithes into. Each account carries its own currency so the church can accept payments in multiple currencies (e.g. NGN, USD).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| bankName | string | |
| accountNumber | string | Indexed |
| accountName | string | |
| currency | string | ISO 4217 code (3 chars). Indexed. |
| description | string | null | Optional note shown to members |
| isActive | boolean | Default true. Inactive accounts are hidden from members. Indexed. |
Indexes: idx_tithe_accounts_account_number, idx_tithe_accounts_currency, idx_tithe_accounts_is_active.
TitheUploadBatch
A batch record created when the finance team uploads an Excel file of tithe payments. Each batch is tied to a specific TitheAccount, so all records in the batch are credited to that account.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| uploadedBy | Admin | ManyToOne |
| titheAccount | TitheAccount | ManyToOne (non-nullable, RESTRICT) |
| fileName | string | |
| status | TitheBatchStatus | PENDING | PROCESSING | COMPLETED | FAILED |
| totalRows | int | Total rows in the spreadsheet |
| matchedRows | int | Rows matched to a member |
| unmatchedRows | int | Rows with no member match |
| disputedRows | int | Rows flagged as possible duplicates |
| rows | jsonb | null | Parsed row data stored for safe requeue |
| errorMessage | string | null | Error detail on FAILED batches |
| processedAt | timestamptz | null | Set when processing completes |
TitheRecord
A confirmed tithe payment matched to a member.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| member | Member | ManyToOne. Indexed. |
| batch | TitheUploadBatch | ManyToOne. Indexed. |
| amount | decimal (12,2) | |
| paymentDate | date | |
| reference | string | null | Optional bank reference |
| bankName | string | null | Sender’s bank from the CSV column — not the destination account |
| source | MANUAL_PROOF | PAYMENT_GATEWAY |
MANUAL_PROOF for CSV batch/proof-of-payment uploads, PAYMENT_GATEWAY for online checkout (see Giving Checkout Module) |
| externalReference | string | null | Only set for PAYMENT_GATEWAY rows — the GivingCheckoutSession id, which is the same reference string sent to the vendor at checkout (GivingCheckoutService.initiateCheckout’s giving_{uuid} reference) — so it’s what shows up in the church’s own Paystack/Flutterwave/etc. dashboard, genuinely reconcilable. GET /admin/tithes/records’s search param matches against it (and reference) in addition to member name/email; the admin UI’s Records tab Reference field displays reference ?? externalReference (the two are never both set on one row) since reference alone stays null for every gateway-sourced record. |
| paymentChannel | string | null | Only set for PAYMENT_GATEWAY rows — the specific vendor (paystack/flutterwave/kora/stripe) the payment cleared through. Admin UI (/finances/tithes, Records tab) surfaces this alongside the Source badge, and it’s included as its own column in the Excel export — needed for settlement/reconciliation, since two different gateways landing in two different merchant accounts both otherwise show only a generic “Gateway” source. |
| givingOption | GivingOption | null | ManyToOne, nullable, SET NULL. Only ever set for PAYMENT_GATEWAY rows where the member designated a purpose at checkout (see GivingOption below) — null means “General Giving,” not a data gap. |
Duplicate detection: (memberId, paymentDate, amount) — if all three match an existing record, the row is flagged as a dispute instead. The destination bank account is inherited from the batch’s titheAccount.
Online giving purpose categorization — GivingOption vs. Pledge (never both): at checkout, a member may optionally designate the payment either toward a GivingOption (Tithe/Offering/General Giving/Building Fund/etc. — admin-curated, see GivingOption below) or toward one of their own active Pledges — never both (InitiateGivingCheckoutDto rejects a request carrying both givingOptionId and pledgeId). A GivingOption designation creates this TitheRecord with givingOption set. A Pledge designation does not create a TitheRecord at all — it creates a PledgeContribution (status CONFIRMED directly, no admin review, since the webhook already verified the money cleared) via PledgeService.recordConfirmedContribution, keeping pledge fulfillment and general giving as genuinely separate ledgers. Designating toward a pledge requires the member to already have an ACTIVE Pledge for that campaign — checkout never auto-creates one on the fly.
Indexes: member_id, batch_id (single-column). Composite IDX_tithe_records_member_payment on (member_id, payment_date) for member giving history range queries.
TitheUnmatchedRecord
Rows from a batch where no member matched the email address.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| batch | TitheUploadBatch | ManyToOne |
| rawEmail | string | Email from the spreadsheet |
| amount | decimal (12,2) | |
| paymentDate | date | |
| reference | string | null | |
| bankName | string | null | |
| status | TitheUnmatchedStatus | PENDING | MATCHED | DISMISSED |
| matchedMember | Member | null | Set when manually resolved |
| resolvedBy | Admin | null | Set when manually resolved |
| resolvedAt | timestamptz | null |
TitheDisputeRecord
Rows that matched a member but would duplicate an existing TitheRecord.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| batch | TitheUploadBatch | ManyToOne |
| existingRecord | TitheRecord | ManyToOne — the conflicting record |
| member | Member | ManyToOne |
| amount | decimal (12,2) | |
| paymentDate | date | |
| reference | string | null | |
| bankName | string | null | |
| status | TitheDisputeStatus | PENDING | APPROVED | REJECTED |
| reviewedBy | Admin | null | |
| reviewedAt | timestamptz | null |
TithePaymentProof
A member-submitted proof of tithe payment awaiting finance-team review. Files are stored in Cloudinary and automatically purged after a configurable number of days (default 90, controlled by TITHE_PROOF_EXPIRY_DAYS).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| member | Member | ManyToOne. Indexed. |
| titheAccount | TitheAccount | ManyToOne (non-nullable, RESTRICT). Indexed. |
| amount | decimal (12,2) | |
| paymentDate | date | Indexed. |
| reference | string | null | |
| givingOption | GivingOption | null | ManyToOne (nullable, SET NULL). Indexed. What the member designated this payment for; null means General Giving. Carried onto the TitheRecord created on confirm. |
| proofUrl | string | Cloudinary secure URL |
| publicId | string | Cloudinary public ID (used for deletion) |
| resourceType | string | Cloudinary resource type returned at upload |
| status | TitheProofStatus | PENDING | CONFIRMED | DECLINED |
| reviewedBy | Admin | null | |
| reviewedAt | timestamptz | null | |
| financeNote | string | null | Reason supplied when declining |
| expiresAt | timestamptz | Set to TITHE_PROOF_EXPIRY_DAYS days from submission (default 90); file purged on expiry |
FinanceCategory
Admin-managed list of expense categories used on finance requests.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | string | Unique |
| description | string | null | |
| isActive | boolean | Default true. Disabling hides the category from the finance-worker picker (GET /finance/categories) without deleting it — categories already referenced by a FinanceRequest can’t be hard-deleted (FK RESTRICT), so disabling is the retirement path for those. |
FinanceRequest
An expense request raised by a department head (HOD).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| requestedBy | Member | ManyToOne — the HOD who submitted the request |
| department | Department | ManyToOne |
| category | FinanceCategory | ManyToOne |
| reason | text | Justification for the expense |
| amount | decimal (12,2) | |
| recipientBankName | string | |
| recipientAccountNumber | string | |
| recipientAccountName | string | |
| attachmentUrl | string | null | Cloudinary URL for optional budget/invoice upload |
| attachmentPublicId | string | null | Cloudinary public ID for attachment (deletion) |
| attachmentResourceType | string | null | Cloudinary resource type returned at upload |
| status | FinanceRequestStatus | PENDING | APPROVED | REJECTED |
| reviewedBy | Admin | null | Set on approve/reject |
| reviewedAt | timestamptz | null | |
| rejectionReason | text | null | Populated on rejection |
| proofUrl | string | null | Cloudinary URL for payment proof, set post-approval |
| proofPublicId | string | null | Cloudinary public ID for proof (deletion) |
| proofResourceType | string | null | Cloudinary resource type for proof |
| journalEntry | JournalEntry | null | ManyToOne, SET NULL — set when a finance-team admin opts to post this request’s payment to the ledger at proof-attachment time; see Finance Request Module below |
FirstTimer
A visitor recorded by a follow-up team worker or admin during or after a service.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| firstname | string | |
| lastname | string | |
| phone | string | |
| string | null | Optional | |
| source | FirstTimerSourceEnum | WALK_IN | ONLINE | REFERRAL |
| wantsToJoinChurch | boolean | Default false. Self-onboard form asks “Just visiting” / “I’d like to stay” on the main screen (unanswered stays false); admins and Follow-Up workers can edit it later |
| enjoyedAboutChurch | text | null | What the visitor enjoyed |
| wantsToJoinWorkforce | boolean | Default false |
| notes | text | null | Additional follow-up notes |
| visitedEvent | Event | null | ManyToOne, SET NULL on delete |
| createdByMember | Member | null | ManyToOne, SET NULL on delete — the follow-up worker who created the record |
| createdByAdmin | Admin | null | ManyToOne, SET NULL on delete — the admin who created the record |
| convertedMember | Member | null | ManyToOne, SET NULL on delete — linked when the first-timer becomes a member |
| convertedAt | timestamptz | null | Timestamp when admin marked this first-timer as converted |
| inviteSentAt | timestamptz | null | Timestamp when membership invitation email was last sent; guards against duplicates |
| followUpTask | FollowUpTask | OneToOne — auto-created on registration |
| visits | FirstTimerVisit[] | OneToMany — return visit records |
FollowUpTask
A task assigned to a follow-up team worker to engage a first-timer or online non-responder.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| type | FollowUpTaskTypeEnum | FIRST_TIMER | ONLINE_NO_RESPONSE | MANUAL |
| status | FollowUpTaskStatusEnum | PENDING | IN_PROGRESS | COMPLETED | UNREACHABLE |
| firstTimer | FirstTimer | null | OneToOne, CASCADE on delete — set when type=FIRST_TIMER |
| member | Member | null | ManyToOne, SET NULL on delete — set when type=ONLINE_NO_RESPONSE. Indexed. |
| event | Event | null | ManyToOne, SET NULL on delete — event context. Indexed. |
| assignedTo | WorkerProfile | ManyToOne, RESTRICT on delete — must be a FOLLOW_UP department worker |
| outcome | FollowUpOutcomeEnum | null | JOINED | DECLINED | NO_ANSWER | PRAYED_WITH |
| outcomeNotes | text | null | |
| dueDate | date | null | Optional target date |
| notes | FollowUpNote[] | OneToMany |
| lastActivityAt | timestamptz | Updated whenever a note is added or status changes; used to detect inactive tasks |
Round-robin assignment: The worker in the FOLLOW_UP department with the fewest open tasks (PENDING or IN_PROGRESS) is automatically selected. If no eligible worker exists, the API returns 400.
FollowUpNote
A note added by the assigned worker during follow-up interactions.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| task | FollowUpTask | ManyToOne, CASCADE on delete. Indexed. |
| addedBy | WorkerProfile | null | ManyToOne, SET NULL on delete |
| content | text | |
| contactMethod | ContactMethodEnum | null | PHONE_CALL | WHATSAPP | IN_PERSON | SMS | EMAIL — optional |
FirstTimerVisit
Records each return visit a first-timer makes before or after converting.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| firstTimer | FirstTimer | ManyToOne, CASCADE on delete. Indexed. |
| event | Event | null | ManyToOne, SET NULL on delete — event attended. Indexed. |
| visitedAt | date | YYYY-MM-DD — date of the visit |
| notes | text | null | Optional observation from the admin |
Convert
An evangelism outreach contact — not assumed to be an existing Member. See the Evangelism Module section below.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | varchar | Required — the only mandatory field on upload |
| phone | varchar | null | |
| notes | text | null | |
| status | varchar | UNSAVED | SAVED | UNDERGOING_DISCIPLESHIP, default UNSAVED. Indexed. |
| onboardedBy | Member | null | ManyToOne, SET NULL on delete. Indexed. |
| onboardedByName | varchar | Snapshotted at upload time |
| assignedTo | WorkerProfile | null | ManyToOne, SET NULL on delete. Indexed. Who is currently following up. |
| member | Member | null | ManyToOne, SET NULL on delete — set once the convert joins as a member |
| linkedAt | timestamptz | null | |
| lastContactedAt | timestamptz | null | Denormalized, updated on every new ConvertFollowUpLog |
ConvertFollowUpLog
One row per contact attempt with a convert — mirrors FirstTimerVisit.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| convert | Convert | ManyToOne, CASCADE on delete. Indexed. |
| loggedBy | Member | null | ManyToOne, SET NULL on delete |
| loggedByName | varchar | Snapshotted at log time |
| note | text | null | |
| contactedAt | timestamptz | Default now |
Sermon
Link-based sermon archive entry — no file uploads. See Sermon Module for the “Announce Live” trigger.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| title | varchar | |
| speakerName | varchar | Plain string, not a Member FK — guest speakers may not be in the system |
| date | timestamptz | Indexed; list is ordered newest-first |
| description | text | null | |
| youtubeUrl | varchar | null | At least one of youtubeUrl/mixlrUrl required |
| mixlrUrl | varchar | null | At least one of youtubeUrl/mixlrUrl required |
| series | varchar | null | Indexed; plain string tag, filterable, not its own entity |
| createdBy | Admin | null | ManyToOne, SET NULL on delete |
Note
A member’s private note (notes, tenant schema). See Notes Module.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| memberId | UUID | FK members, CASCADE; index (member_id, updated_at) |
| kind | varchar | sermon | personal | study (default personal) |
| title | varchar(200) | Default '' |
| content | jsonb | Editor document ({ type: 'doc', content: [...] }), max 200 KB |
| plainText | text | Derived on save; search and excerpts |
| scriptureRefs | jsonb (string[]) | Derived canonical refs, e.g. ROM.8.28 |
| commitment | varchar(200) | null | Derived from the “One thing I’ll do this week” prompt |
| wordCount | int | Derived; words outside headings (0 = untouched template) |
| sermonId | UUID | null | FK sermons, SET NULL; indexed |
| eventId | UUID | null | FK events, SET NULL; indexed |
| serviceSlotId | UUID | null | FK service_slots, SET NULL; unique with memberId when set |
| pinned | boolean | Default false |
ScriptureLinkTap
Daily totals of taps on copyrighted Bible versions that open on bible.com (scripture_link_taps, tenant schema).
PK (day, version); count int.
members.note_nudges (boolean, default true) — the member’s own switch for Notes reminders.
ServiceProgramme
One programme per service slot (unique constraint on service_slot_id). Status flows: DRAFT → LIVE → COMPLETED.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| serviceSlot | ServiceSlot | OneToOne, CASCADE on delete |
| status | varchar | DRAFT | LIVE | COMPLETED |
| saveAsTemplate | boolean | If true, upserts template on completion |
| createdByAdmin | Admin | null | ManyToOne, SET NULL on delete |
GET /service-programme and GET /service-programme/:id responses also include derived (non-persisted) fields for the admin frontend: serviceSlotId, serviceSlotName ("{eventName} — {slotName}"), and slotCount.
ServiceProgrammeSlot
Ordered items within a programme. Frozen when session starts; runtime changes go to ServiceSessionSlot.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| programme | ServiceProgramme | ManyToOne, CASCADE on delete |
| position | int | Zero-based order index |
| type | varchar | SPEAKER | BREAK |
| topic | varchar | null | |
| member | Member | null | Assigned speaker; SET NULL on delete |
| guestName | varchar | null | Free-text name for non-members |
| backupMember | Member | null | Backup speaker; SET NULL on delete |
| backupGuestName | varchar | null | |
| allocatedMinutes | int | Planned slot duration |
| reminderSentAt | timestamptz | null | Set once the day-before reminder email has been sent; prevents duplicate sends |
ServiceSession
One session per programme (unique constraint on programme_id). Created when a session starts.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| programme | ServiceProgramme | OneToOne, CASCADE on delete |
| sessionCode | varchar | Unique, e.g. SVC-ABC123 |
| status | varchar | LIVE | COMPLETED |
| startedAt | timestamptz | |
| endedAt | timestamptz | null |
Redis anchor (session:{sessionCode}:anchor, TTL 48 h after completion):
{ "currentSlotPosition": 0, "slotStartedAt": 1718000000000, "slotBaseSeconds": 0,
"status": "LIVE", "isPaused": false, "pausedAt": null }
Clients compute elapsed = slotBaseSeconds + (Date.now() - slotStartedAt) / 1000. No server-side ticker.
ServiceSessionSlot
Snapshot of each programme slot at session start. Runtime overrides stored here; planned data stays on ServiceProgrammeSlot.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| session | ServiceSession | ManyToOne, CASCADE |
| programmeSlot | ServiceProgrammeSlot | ManyToOne, CASCADE |
| position | int | |
| status | varchar | PENDING | IN_PROGRESS | COMPLETED | SKIPPED |
| adjustedAllocatedMinutes | int | null | Runtime time override |
| overriddenTopic | varchar | null | |
| overriddenSpeakerName | varchar | null | Display-only; analytics still uses member FK |
| overriddenMember | Member | null | If actual speaker changed mid-session |
| actualSeconds | int | null | Measured speaking time |
| startedAt | timestamptz | null | |
| completedAt | timestamptz | null |
ServicePauseEntry
One row per pause event during a session.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| session | ServiceSession | ManyToOne, CASCADE |
| slotPosition | int | Slot active at pause time |
| reason | varchar | ServicePauseReasonEnum |
| pausedAt | timestamptz | |
| resumedAt | timestamptz | null | Null until resumed |
ServiceActionEntry
Audit log of all control actions taken during a session.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| session | ServiceSession | ManyToOne, CASCADE |
| actorRole | varchar | ADMIN | WORKER | PUBLIC_LINK |
| action | varchar | e.g. ADVANCE_SLOT, PAUSE, TIME_ADJUSTED, SLOTS_REORDERED |
| detail | varchar | null | |
| performedByMember | Member | null | SET NULL on delete |
ServiceProgrammeTemplate
Auto-upserted when a session with saveAsTemplate = true completes. Minister assignments are always blank — only structure is saved.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | varchar | e.g. “First Service” |
| serviceSlotName | varchar | Match key for auto-suggestion |
| slots | jsonb | [{ position, type, topic, allocatedMinutes }] |
| createdFrom | ServiceProgramme | null | SET NULL on delete |
ServiceHeadcount
Physical attendance count record for one service slot, broken down by demographic group.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| serviceSlot | ServiceSlot | OneToOne (unique), CASCADE on delete |
| maleAdults | int | Default 0 |
| femaleAdults | int | Default 0 |
| teenagers | int | Default 0 |
| children | int | Default 0 |
| mobileChurch | int | Default 0 — count from the mobile outreach venue (fixed group) |
| customGroups | jsonb | Record<string, number> — extensible free-form groups |
| recordedBy | Admin | null | ManyToOne, SET NULL on delete — admin who submitted the record |
| notes | text | null | Optional context note for the record |
Computed field: total is not stored. It is computed on every read as the sum of all five fixed columns plus all values in customGroups. The value is appended to each response object.
PrayerProgram
A named, configurable prayer program. All prayer entities (day configs, rules, meetings, roster entries) are scoped to a program, enabling multiple concurrent programs (e.g. “Morning Intercessory” for workers and “Friday Night” open to all members).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | string | |
| description | text | null | Optional |
| audience | PrayerAudience | WORKERS | MEMBERS | ALL — controls who may be assigned/self-select |
| selectionWindowDays | int | Days before meeting when self-selection opens. Default 7. |
| isActive | boolean | Inactive programs are excluded from normal operations. Default true. |
Audience rules: WORKERS-audience programs use auto-assign; MEMBERS-audience programs use self-selection and manual assignment only; ALL programs combine both.
PrayerScheduleConfig
Global configuration for the prayer roster module (legacy — predates multi-program support). One active record at a time. New installations use PrayerProgram.selectionWindowDays instead.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| selectionWindowDays | int | Number of days before a meeting that self-selection is open. Default 7. |
| isActive | boolean | Only one active config is used at a time |
PrayerDayConfig
Defines which days of the week prayer meetings occur and their capacity/mode, scoped to a program.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| program | PrayerProgram | ManyToOne, RESTRICT on delete. Indexed. |
| dayOfWeek | int | 0 = Sunday … 6 = Saturday (JS Date.getDay) |
| mode | PrayerDayMode | PHYSICAL | VIRTUAL |
| startTime | string (HH:mm) | Default 00:00 |
| endTime | string (HH:mm) | Default 01:00 |
| maxCapacity | int | Max assignees for this day |
| isActive | boolean | Inactive configs are skipped during generation |
Unique constraint (application-level): Only one active config per (program, dayOfWeek) pair.
PrayerScheduleRule
Configurable rules that govern frequency and capacity requirements, scoped to a program.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| program | PrayerProgram | ManyToOne, RESTRICT on delete. Indexed. |
| type | PrayerRuleType | ROLE_FREQUENCY | MIN_LEADERS_PER_MEETING | MAX_PER_MEETING |
| targetLeadType | DepartmentLeadTypeEnum | null | null = applies to all workers; set for HOD/D_HOD overrides |
| value | int | Times per month for ROLE_FREQUENCY; head-count for others |
| description | string | Human-readable label |
| isActive | boolean | Inactive rules are ignored during assignment |
Seeded defaults (on the default program): worker frequency = 1, HOD frequency = 2, D_HOD frequency = 2, min leaders per meeting = 1, max per meeting = 5.
PrayerFixedAssignment
Permanently pins a worker to a specific prayer day across all months, within a program.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| workerProfile | WorkerProfile | ManyToOne, CASCADE on delete |
| dayConfig | PrayerDayConfig | ManyToOne, CASCADE on delete |
| isActive | boolean | Soft-disable without deleting the assignment |
Unique constraint: (workerProfile, dayConfig) — one fixed assignment per worker per day config.
PrayerMeeting
One concrete meeting per calendar date generated from a day config, scoped to a program.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| program | PrayerProgram | ManyToOne, RESTRICT on delete. Indexed. |
| date | string (YYYY-MM-DD) | Actual meeting date. Indexed. |
| month | int | Calendar month (1–12). Indexed. |
| year | int | Calendar year. Indexed. |
| dayConfig | PrayerDayConfig | ManyToOne, RESTRICT on delete |
| status | PrayerMeetingStatus | SCHEDULED | COMPLETED | CANCELLED. Indexed. |
| selectionStatus | PrayerWindowStatus | PENDING | OPEN | CLOSED. Indexed. |
| currentCapacity | int | Current number of assigned workers/members |
| rosterEntries | PrayerRosterEntry[] | OneToMany |
PrayerRosterEntry
One assignment of a worker or member to a prayer meeting. Exactly one of workerProfile or member is set; the other is null.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| workerProfile | WorkerProfile | null | ManyToOne, CASCADE on delete. Indexed. Null for member-only assignments. |
| member | Member | null | ManyToOne, CASCADE on delete. Indexed. Null for worker assignments. |
| meeting | PrayerMeeting | ManyToOne, CASCADE on delete. Indexed. |
| assignmentType | PrayerAssignmentType | FIXED | SELF_SELECTED | AUTO_ASSIGNED | MANUAL |
| status | PrayerRosterStatus | SCHEDULED | RESCHEDULED |
| rescheduledFrom | PrayerRosterEntry | null | Self-referencing nullable FK, SET NULL on delete — tracks origin of rescheduled entries |
| reminderTwoDaySent | boolean | 2-day-ahead reminder dispatched flag. Indexed (scheduler filter). |
| reminderDaySent | boolean | Day-of reminder dispatched flag. Indexed (scheduler filter). |
RentalFacility
A bookable space (hall, room, etc.) owned by the congregation.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | varchar | Unique display name |
| description | text | nullable |
| basePrice | decimal | Price before discount (15,2) |
| capacity | int | nullable — max occupancy |
| isActive | boolean | Soft-disable without deleting |
RentalPricingTier
One discount rule per member category. Unique on memberCategory.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| memberCategory | RentalMemberCategory | MEMBER | WORKER | LEADER | PUBLIC — UNIQUE |
| discountType | RentalDiscountType | PERCENTAGE | FLAT |
| discountValue | decimal | % value or flat amount (10,2) |
| isActive | boolean |
RentalAddon
Bookable extras (LED screen, décor, etc.) with an optional asset link.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | varchar | |
| description | text | nullable |
| price | decimal | Service charge, subject to discount (15,2) |
| cautionAmount | decimal | Refundable deposit — never discounted (15,2) |
| isActive | boolean | |
| asset | Asset | nullable FK → assets, SET NULL on delete |
RentalCalendarBlock
Admin-created blackout period on a facility (maintenance, church events, etc.).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| facility | RentalFacility | ManyToOne, CASCADE on delete |
| startDateTime | timestamptz | |
| endDateTime | timestamptz | |
| reason | text | nullable |
RentalBooking
A member’s booking of a facility for a specific time window. Price snapshot is stored at creation time so later config changes do not affect existing bookings.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| facility | RentalFacility | ManyToOne, RESTRICT on delete |
| member | Member | ManyToOne, RESTRICT on delete |
| startDateTime | timestamptz | Indexed |
| endDateTime | timestamptz | |
| status | RentalBookingStatus | PENDING → CONFIRMED → IN_PROGRESS → COMPLETED | CANCELLED | REJECTED |
| memberCategory | RentalMemberCategory | Snapshot of category at booking time |
| basePrice | decimal | Snapshot of facility base price |
| discountType | RentalDiscountType | nullable — applied discount type |
| discountValue | decimal | nullable — applied discount amount/percent |
| discountSource | RentalDiscountSource | NONE | TIER | OVERRIDE |
| serviceFee | decimal | (base + addons) after discount |
| cautionTotal | decimal | Sum of all caution amounts — never discounted |
| grandTotal | decimal | serviceFee + cautionTotal |
| overrideDiscountType | RentalDiscountType | nullable — admin override |
| overrideDiscountValue | decimal | nullable |
| overrideDiscountNote | text | nullable — reason for override |
| purpose | text | nullable |
| notes | text | nullable — admin notes |
| rejectionReason | text | nullable |
RentalBookingAddon
Junction between a booking and selected add-ons. Stores unit price/caution snapshots.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| booking | RentalBooking | ManyToOne, CASCADE on delete |
| addon | RentalAddon | ManyToOne, RESTRICT on delete |
| quantity | int | Default 1 |
| unitPrice | decimal | Snapshot of addon.price at booking time |
| unitCaution | decimal | Snapshot of addon.cautionAmount |
RentalPayment
One payment line per booking (service fee + separate caution record if caution > 0). Tracks proof and refund lifecycle.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| booking | RentalBooking | ManyToOne, CASCADE on delete. Indexed. |
| type | RentalPaymentType | SERVICE_FEE | CAUTION |
| amount | decimal | |
| status | RentalPaymentStatus | PENDING → PAID; CAUTION can transition to REFUNDED |
| paidAt | timestamptz | nullable |
| refundedAt | timestamptz | nullable — set when caution returned |
| reference | varchar | nullable — bank ref / receipt number |
| proofUrl | varchar | nullable |
Game
A reusable Kahoot-style quiz definition — created on the admin portal, can back multiple GameSessions over time.
department/churchClass are categorization only, not access control (see Games Module).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| title | varchar | |
| description | text | null | |
| status | GameStatusEnum | DRAFT | LIVE_SESSION_ACTIVE | ARCHIVED (ARCHIVED not yet exposed via any endpoint) |
| createdBy | Admin | null | ManyToOne, SET NULL on delete |
| department | Department | null | ManyToOne, SET NULL on delete — reporting/filtering only |
| churchClass | ChurchClass | null | ManyToOne, SET NULL on delete — reporting/filtering only |
GameQuestion
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| game | Game | ManyToOne, CASCADE on delete. Indexed. |
| order | int | Zero-based display/play order within the game |
| questionText | text | |
| options | jsonb | Array of option strings, minimum 2 |
| correctOptionIndex | int | Index into options; validated on create/update |
| points | int | Default 1000 — base score before the speed bonus |
| timeLimitSeconds | int | Default 20 |
GameSession
One “run” of a Game. sessionCode is the join credential (GAME-XXXXXX).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| game | Game | ManyToOne, CASCADE on delete. Indexed. |
| sessionCode | varchar, unique | |
| status | GameSessionStatusEnum | SCHEDULED | LIVE | ENDED (SCHEDULED not yet reachable — sessions start directly into LIVE) |
| hostAdmin | Admin | null | ManyToOne, SET NULL on delete — the only admin who can advance questions (see Games Module — ending a session is deliberately NOT host-restricted) |
| currentQuestionIndex | int | null | Null before start |
| currentQuestionStartedAt | timestamptz | null | Server-side clock all participants are scored against |
| startedAt / endedAt | timestamptz | null |
GameParticipant
A member’s membership in one GameSession, with their running score. @Unique(['session','member']) — joining
again just returns the existing row.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| session | GameSession | ManyToOne, CASCADE on delete. Indexed. |
| member | Member | ManyToOne, CASCADE on delete |
| totalScore | int | Default 0; incremented per correct response |
GameResponse
One row per (session, question, participant) answer — the unique constraint is the DB-level backstop against double-answering. Individual responses are not audit-logged (too high-frequency/low-stakes).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| session | GameSession | ManyToOne, CASCADE on delete. Indexed. |
| question | GameQuestion | ManyToOne, CASCADE on delete |
| participant | GameParticipant | ManyToOne, CASCADE on delete |
| selectedOptionIndex | int | |
| isCorrect | boolean | |
| pointsAwarded | int | 0 for incorrect; speed-weighted for correct (see Games Module) |
| answeredAt | timestamptz |
Unique constraint: (session, question, participant).
4. Authentication & Authorization
Dual-Surface Sessions
The app has two independent entry points — the mobile app (POST /auth/login) and the admin portal (POST /auth/admin-login) — and each maintains its own session row in member_sessions. The surface column (MEMBER | ADMIN) is the discriminator; a unique constraint on (member_id, surface) ensures at most one active session per surface per user.
JWT payload now includes aud (audience) to identify the surface:
{ "sub": "<memberId>", "role": "MEMBER|WORKER", "aud": "MEMBER|ADMIN" }
Surface enforcement:
JwtStrategycallsvalidateAccessToken(sub, aud)— it looks up the session row for that specific(memberId, surface)pair. An admin token used on a mobile endpoint checks theADMINsession; if the user has no admin session, the request is rejected with 401.AdminGuardadditionally checksrequest.user.surface === 'ADMIN'. A member token (aud: MEMBER) used on an admin-portal endpoint is rejected with 403 before the admin DB lookup even runs.POST /auth/logoutis surface-scoped: it readsreq.user.surfacefrom the validated token and deletes only that session row, leaving the other surface’s session intact.- Password reset and device reset/purge invalidate both surfaces simultaneously (credential change = full sign-out).
There is no ADMIN role in the JWT. Admin portal access is determined at the route level by AdminGuard looking up the admins table.
validateAccessToken returns MemberAuth which is set as req.user. For WORKER-role members, req.user.workerProfileId is populated from the loaded workerProfile.id. Worker-facing endpoints that need the worker’s profile ID read it from req.user.workerProfileId — this is never embedded in the JWT itself. HOD status is not carried on MemberAuth; it is resolved once on GET /auth/me (see above) and can be cached by the client. Server-side HOD-gated endpoints query department_leads directly when they need it.
Guards
- ThrottlerGuard — applied globally via
APP_GUARD. Rate-limits every endpoint toTHROTTLE_LIMITrequests perTHROTTLE_TTL_MS-millisecond window per IP (defaults: 100 req / 60 s). Returns HTTP 429 when the limit is exceeded. TheGET /healthendpoint is exempt via@SkipThrottle().GET /service-session/:code/stateandGET /service-session/:code/slots/:positionare overridden to 300 req/60s via@Throttle()— these are public, read-only routes; the override exists for the initial page-load fetch and the (now much less frequent) safety-net poll each live-session view keeps as a fallback — see the Socket.IO section below for why per-IP throttling was never the real scaling lever for this module, since a per-IP cap does nothing to bound aggregate load across the hundreds of distinct devices/IPs a single popular session’s Audience view can attract. - JwtAuthGuard — applied globally via
APP_GUARD. All routes are protected unless decorated with@Public(). - PasswordChangeRequiredGuard — applied globally via
APP_GUARD(runs afterJwtAuthGuard). Blocks all requests with HTTP 403PASSWORD_CHANGE_REQUIREDif the authenticated user haschangedPassword = false(i.e. they are on a system-generated temporary password). Exempt routes must be decorated with@SkipPasswordChangeCheck():POST /auth/refresh,POST /auth/logout,GET /auth/me,POST /auth/change-password. - RolesGuard — applied per-route via
@Roles(MemberRoleEnum.WORKER). Checksrequest.user.rolefor worker-only routes (mobile app). - AdminGuard — applied per-route via
@UseGuards(AdminGuard). First checksrequest.user.surface === 'ADMIN'(rejects member tokens with 403), then queries theadminstable to verify an active Admin record, then checks@RequiresPermission(AdminPermission.X)metadata. Setsrequest.adminfor downstream use. Used exclusively on admin portal routes. - LocalAuthGuard — used on
POST /auth/login(mobile) andPOST /auth/admin-login(web portal) to invoke the Passport local strategy. - RefreshJwtAuthGuard — used on
POST /auth/refresh.
Token Lifecycle
Refresh token delivery and transport differ by surface:
| Surface | Refresh token on login | Refresh on POST /auth/refresh |
Logout |
|---|---|---|---|
| ADMIN (web portal) | Set as httpOnly; SameSite cookie on /v1/auth/refresh path only — not in the response body |
Cookie sent automatically by browser; new cookie set in response | Cookie cleared |
| MEMBER / WORKER (mobile) | Returned in response body (refresh_token) |
Sent in Authorization: Bearer header |
Session row cleared |
- Login → receives
access_token+requires_password_change(andrefresh_tokenin body for mobile only). A surface-scoped session row is created (or updated) with a hashed refresh token. Ifrequires_password_changeistrue, the client must redirect the user toPOST /auth/change-passwordbefore allowing any other action. - Access token expires → call
POST /auth/refresh. Admin web clients rely on the httpOnly cookie (sent automatically); mobile clients send the refresh token in theAuthorization: Bearerheader. The refresh token carriesaudand renews the same-surface session. - Logout → clears the session row for the caller’s surface. For the ADMIN surface the httpOnly cookie is also cleared. The other surface’s session is unaffected.
Admin cookie secure/sameSite flags are NODE_ENV !== 'development', not NODE_ENV === 'production'. The admin web app calls the API cross-site with withCredentials: true, which requires secure: true; sameSite: 'none' — browsers drop any cookie without those flags on a cross-site request. Railway always serves over HTTPS regardless of environment name, so test/staging deployments need the same secure cookie behavior as production; only local dev runs over plain HTTP and needs the relaxed lax/non-secure cookie. Checking === 'production' previously meant any other deployed NODE_ENV value (e.g. test) silently downgraded to a cookie that cross-site browsers refuse to send back on refresh, dropping the admin session on every refresh call.
Refresh Token Rotation & Reuse Detection
Every call to POST /auth/refresh performs a full rotation:
- A new refresh token is issued and its hash replaces the previous one in
member_sessions. - The previous hash, plus the full token response that was just issued and the rotation timestamp, are stored together in Redis under
rt_rotated:{memberId}:{surface}for the duration of the refresh token’s TTL. - If an already-rotated token is presented (i.e. the hash matches the Redis entry but not the current session hash), the server checks how long ago the rotation happened:
- Within the reuse grace window (10s) — treated as a benign concurrent-request race, not theft (e.g. two browser tabs on the same admin login, or the proactive pre-expiry refresh racing a reactive 401-triggered refresh). The server does not rotate again or touch the session; it replays the exact tokens issued by the rotation that already happened, so both callers converge on the same valid pair.
- Outside the grace window — treated as credential reuse, the server immediately invalidates the entire session for that surface, and returns HTTP 401. This limits the blast radius of a stolen refresh token to a single use.
- On reuse detection (outside the grace window) the member receives a
session-security-alertemail advising them to change their password if the sign-out was unexpected. - If the member row disappears before the rotated session is saved, the matching
member_sessions.member_idforeign-key violation returns HTTP 401 with a sign-in-again message. Other database errors are not converted.
Absolute Session Lifetime
Each session row in member_sessions is upserted per member + surface — updateLogin() reuses the existing row across logins rather than creating a new one, updating only hashedRefreshToken/lastLogin/lastLogout. createdAt (from BaseEntity) is therefore set once at the row’s first-ever login and never moves again — it is not a valid anchor for “how long has this login been going.” On every refresh request, validateRefreshToken checks:
Date.now() - session.lastLogin > SESSION_MAX_AGE_DAYS × 86 400 000 ms
(Anchored on lastLogin, which resets on every login, not createdAt — using createdAt would mean any member whose session row is older than SESSION_MAX_AGE_DAYS gets force-logged-out on the very first refresh after every future login, no matter how recently they signed in.)
If the threshold is exceeded the session is invalidated and HTTP 401 is returned, forcing a fresh login regardless of how recently the token was rotated. SESSION_MAX_AGE_DAYS defaults to 30 and is configurable via environment variable.
Temporary Password Flow
All new accounts — whether created via signup or admin-elevated — receive a server-generated temporary password. The
changedPassword flag on Member is set to false. On first login:
- The login response includes
"requires_password_change": true. - The
PasswordChangeRequiredGuardblocks every subsequent authenticated request except the four exempt routes above. - The user must call
POST /auth/change-password(supplying the emailed temporary password asoldPassword) to activate full access. - Once changed,
changedPasswordis set totrueand normal access resumes.
Signup: POST /auth/signup no longer accepts a password field. The server generates a secure random password,
hashes it, sets changedPassword = false, and emails the plaintext temporary password to the new member.
The member app’s signup is a single screen — first/last name, email, optional phone, gender and birthday. Marital
status and the church journey (dateJoinedChurch, yearBornAgain, yearBaptized, baptizedWithHolyGhost) are
added afterwards from Edit Profile, prompted by a “Next Steps” card on Home. SignupDto still accepts every field
(older clients, admin create); joinWorkforce: true now records serveInterestAt instead of being discarded.
Device Lock (Mobile App)
Only one device may be logged into the mobile app per member account. This prevents proxy check-ins.
POST /auth/loginrequires adeviceIdstring in the request body (the mobile client’s device fingerprint).- On the member’s first login (
member.deviceIdisnull), the device is registered and login succeeds. - On subsequent logins, the incoming
deviceIdis compared to the stored value. If they match, login succeeds. If they differ, HTTP 403 is returned. - An admin can purge the device lock via
DELETE /admin/members/:id/device(MEMBERS_WRITEpermission). This setsdeviceId = nulland invalidates all active sessions for that member, forcing a fresh login from any device. POST /auth/admin-login(web portal) does not perform a device check — it is web-first.
Bug fixed: the login-notification email (EmailCategory.LOGIN_ALERT, subject “New … Login Detected”) used to
send on every successful login, not just a new-device registration — despite the email itself claiming “We
detected a new login.” AuthService.login() now captures isNewDeviceRegistration = !member.deviceId before
setDeviceId() mutates it, and only queues the email when that’s true — i.e. this member’s first-ever login, or
their first login after a device reset (the only two ways deviceId transitions from null). Every other login
(the normal case: deviceId already matches) no longer emails at all. The EmailCategorySettingsService/
ENFORCE_DISTANCE_CHECK-style tenant-level toggle for LOGIN_ALERT (see “Email Category Settings Module”) still
applies on top of this — it can suppress the email entirely for a tenant, but doesn’t affect when within a
tenant it would have fired.
Self-Service Device Reset Flow
A member who needs to log in from a new device (lost phone, factory reset, etc.) can reset their own device lock without admin involvement, subject to a rate limit.
POST /auth/device-reset/request— accepts{ email, newDeviceId }. Rate-limited per email (default: 3 attempts per 24-hour window, configurable viaDEVICE_RESET_MAX_ATTEMPTSandDEVICE_RESET_WINDOW_SECONDS). Generates a 6-digit OTP, stores an Argon2 hash and thenewDeviceIdindevice_reset_otps, and emails the code. Always returns the same success message to avoid leaking account existence.- Security note:
newDeviceIdis locked in at request time. An attacker who intercepts the OTP cannot redirect the reset to their own device — the device is bound to whoever initiated the request.
- Security note:
POST /auth/device-reset/verify— accepts{ email, otp }. Verifies the OTP, checks expiry, marks the record as used, updatesmember.deviceIdto thenewDeviceIdstored on the OTP record, invalidates all active sessions, unsubscribes push, revokes every one of the member’s WebAuthn credentials (WebauthnService.revokeAllCredentials), and sends a confirmation email. On success the member must log in fresh from the new device, re-enrolling biometrics if they want one-tap login again.- Why WebAuthn credentials are revoked too: a WebAuthn credential is hardware-bound and deliberately never
checks
deviceId(seeloginWithWebauthn’s own comment) — several trusted devices are meant to each hold their own credential. Without this, a lost/stolen device’s fingerprint or Face ID would keep working right through a device reset, since that lock only ever gated the password path. There’s no way to isolate which single stored credential belongs to the lost device, so a device reset revokes all of them rather than leaving any possibly compromised one live. - If the attempt count reaches the configured maximum, the email is rate-limited and the member must contact an
admin for an out-of-band device purge (
DELETE /admin/members/:id/device). - Wrong-guess limiting: see “OTP Verify Guess Limiting” below — this endpoint is one of the three protected.
- Why WebAuthn credentials are revoked too: a WebAuthn credential is hardware-bound and deliberately never
checks
OTP Verify Guess Limiting
FORGOT_PASSWORD_MAX_ATTEMPTS/DEVICE_RESET_MAX_ATTEMPTS (and the per-route @Throttle decorators) only cap how
often a new OTP can be requested — none of them capped how many guesses could be made against an OTP that was
already issued. A 6-digit code has only 1,000,000 possible values, so without a separate per-account guess limit a
distributed attacker (rotating source IPs past the per-IP @Throttle) could brute-force a live code within its
OTP_TTL_SECONDS validity window.
AuthService.checkOtpVerifyRateLimit/recordFailedOtpVerify/clearOtpVerifyRateLimit add a per-account counter
(Redis key otp_verify_fail:<identifier>:<scope>, TTL = OTP_TTL_SECONDS) on top of the existing request-side
limits — mirrors checkLoginRateLimit’s shape. Every wrong or expired/missing-code attempt increments the counter;
a correct verify clears it. Once OTP_VERIFY_MAX_ATTEMPTS (default 5) failed attempts accumulate, the endpoint
returns 429 TOO_MANY_REQUESTS — the account must wait out the window or request a fresh OTP (which doesn’t reset
this counter, only reissuing the underlying code does something new to guess). Applies independently, keyed per
scope, to all three OTP-verify endpoints:
POST /auth/reset-password(scope: 'password_reset', identifier: email)POST /auth/device-reset/verify(scope: 'device_reset', identifier: email)POST /auth/email-change/confirm(scope: 'email_change', identifier: member id) — this route previously had no@Throttleat all; it now has the same5/minper-IP throttle as the other two, plus this per-account guard.
Forgot Password / OTP Reset Flow
POST /auth/forgot-password— rate-limited (default: 3 attempts per hour, configurable via env). Generates a 6-digit OTP, stores an Argon2 hash inpassword_reset_otps, and emails the code. Always returns the same success message to avoid leaking account existence.POST /auth/reset-password— rate-limited (5 attempts/min, same asforgot-password). Verifies the OTP against the hash, checks expiry (default: 15 min for a self-requested reset — longer for a tenant-welcome OTP, see below), marks the OTP as used, updates the password, invalidates any existing session, and emails a confirmation. On success the user must log in fresh.- Wrong-guess limiting: see “OTP Verify Guess Limiting” above.
This endpoint is also how a brand-new tenant’s first admin sets their initial password — see “Tenant Welcome / Set Password Flow” below.
Password requirements (enforced identically on login, reset, change-password, and platform-admin reset — each
DTO declares its own class-validator rules rather than sharing one, so the two identity systems stay independently
changeable): minimum 8 characters, at least one uppercase letter, at least one number, and at least one non-alphanumeric
character (/[^A-Za-z0-9]/ — any symbol qualifies, not a fixed whitelist). The special-character check was widened
from a fixed set (@$!%*?&) to any symbol so that passwords generated by browser/OS password managers (which often
pick a symbol outside a narrow whitelist) aren’t rejected.
Self-Service Email Change Flow
A logged-in member/worker can change their own email address without admin involvement. Unlike the forgot-password
and device-reset flows above, both routes require an authenticated session (JwtAuthGuard via the global guard, no
@Public()) — the OTP is a second factor confirming ownership of the new mailbox, not a way to prove account
ownership from scratch.
POST /auth/email-change/request— accepts{ newEmail }. Returns409 ConflictifnewEmailis already used by another member. Rate-limited the same way asforgot-password(checkOtpRateLimit, keyed on the caller’s member id). Deletes any prior unused request, generates a 6-digit OTP, stores an Argon2 hash and the targetnewEmailinemail_change_otps(mirrorsDeviceResetOtp’s pattern of locking in the sensitive value at request time), and emails the code to the new address (not the current one) — this doubles as proof the caller controls it.POST /auth/email-change/confirm— accepts{ otp }. Verifies the OTP and expiry (OTP_TTL_SECONDS) against the caller’s own most recent unused record, re-checks thatnewEmailis still unclaimed (409on a race), marks the record used, updatesmember.emailto the storednewEmail, and emails a confirmation to the new address.- Wrong-guess limiting: see “OTP Verify Guess Limiting” above.
Tenant Welcome / Set Password Flow
Neither public entry point — self-serve POST /signup nor platform-admin POST /platform/tenants — collects a
password for the tenant’s first admin. SignupDto has no adminPassword field at all: letting an unauthenticated
signup form set a password directly would mean anyone could submit an email address they don’t own, with no proof of
control over that inbox. TenantProvisioningService.provision()/seedTenantAdmin() still branches on whether
adminPasswordHash was supplied, but in practice only one caller ever supplies it today — the provision:tenant CLI
script (src/provision-tenant.ts), an internal ops tool run by trusted staff, not a public HTTP surface:
- No
adminPasswordHashsupplied (bothPOST /signupandPOST /platform/tenants, i.e. the normal case):seedTenantAdmin()generates a random password viaUtilityService.generateRandomPassword()and hashes it — this password is never revealed to anyone, including the caller who triggered provisioning.changedPassword: false. It also generates a 6-digit OTP and stores its hash inpassword_reset_otps(the same table the forgot-password flow uses) with a 48-hour expiry (WELCOME_OTP_TTL_HOURS) — deliberately longer than the 15-minute forgot-password OTP, since a new admin may not check their email the same day. Once the tenant is active,provision()fire-and-forgets a warm welcome email (tenant-welcometemplate) to the new admin containing the OTP and adiscuva-admin(formerlyFaithapp-admin)/set-password?email=...&otp=...link. That page pre-fills the code and calls the existingPOST /auth/reset-password— the same endpoint and verification logic the forgot-password flow uses, just reached from a different starting point. The longer OTP window is offset by rate-limitingPOST /auth/reset-passworditself (5 attempts/min). Clicking that link and setting a password is also, in effect, email verification — nobody can ever log in to a self-serve-signed-up tenant without proving control of the inbox behindadminEmail. adminPasswordHashsupplied (CLI only):changedPassword: true, no OTP generated, no welcome email sent.
Async Tenant Provisioning + Onboarding State Machine
TenantProvisioningService.provision() (CREATE SCHEMA + run the tenant migration set + seed the first admin +
create a Subscription) runs async, on a Bull queue (TENANT_PROVISIONING_QUEUE,
TenantProvisioningProcessor) for self-serve POST /signup only. POST /platform/tenants runs it inline —
see “Platform-admin tenant creation is synchronous” below for why the two callers deliberately differ. provision()
itself is unchanged either way and still callable directly (the provision:tenant CLI script does) — it’s already
idempotent (checks existing state before acting at every step), which is what makes it safe for the queue to retry
and safe to re-run by hand after a synchronous failure.
Tenant.onboardingStatus (PENDING | AWAITING_APPROVAL | PROVISIONING | ACTIVE | FAILED) is orthogonal to isActive — isActive
still means “currently allowed to serve live traffic” (also flipped by PlatformTenantService.suspendTenant);
onboardingStatus only ever moves forward through the lifecycle once and never changes on suspend/reactivate.
Deleting an invalid/incomplete signup: DELETE /platform/tenants/:id (PlatformTenantService.deleteTenant,
TENANTS_DELETE permission) is the only hard-delete path for a tenant, and it’s deliberately narrow — only
PENDING (never provisioned) or FAILED (provisioning attempt died) tenants qualify; an ACTIVE one, even later
suspended, is refused with 409 since that’s real church data. Drops the Postgres schema first (DROP SCHEMA IF EXISTS "..." CASCADE, safe as a no-op for a PENDING tenant that never reached provision()), then removes the
tenants row, which cascades onboarding events/subscription/etc. via existing FKs. No automated sweep for
abandoned signups exists yet — this is a manual action from the discuva-platform tenant detail panel, gated behind
a “type the subdomain to confirm” step given it’s the one irreversible tenant action.
Subdomain validation happens inside ensurePendingTenant, before touching the database, against two separate
blocklists — 403/409 ConflictException either way, but for different reasons: RESERVED_SUBDOMAINS
(src/tenant/utility/extract-subdomain.ts — www/api/admin/platform/app) are words that would actually
break routing if claimed, blocked for every caller including platform admins; GENERIC_OR_ABUSE_PRONE_SUBDOMAINS
(src/tenant/constants/blocked-signup-subdomains.constant.ts — test/dev/demo/staging/login/billing/etc.,
grouped by reason in the file itself) is a policy call against free-tier squatting and phishing-adjacent names, and
can be bypassed via ensurePendingTenant’s 4th param (allowGenericSubdomain) — set only by
PlatformTenantService.createTenant, since a platform admin deliberately creating e.g. a real sales-demo tenant at
demo.<domain> is a trusted, authenticated action, not the abuse case this list exists to stop.
Self-serve signup flow (async):
TenantProvisioningService.ensurePendingTenant(subdomain, churchName, parentTenantId?)— the find-or-create part of the oldprovision(), now its own method — creates theTenantrow (onboardingStatus: PENDING) or returns the existing one if resuming. Callable standalone specifically soSignupControllergets a realtenant.idback before handing off to the queue.recordEvent(tenantId, 'SIGNUP_INITIATED', SELF_SERVE)— see the audit trail note below.- A
TENANT_PROVISIONING_JOBis enqueued (ProvisionTenantParams+tenantId+actorType/actorId+branchInviteToken),attempts: 3, backoff: { type: 'exponential', delay: 5000 }(this codebase’s standard retry convention, e.g.TitheProcessor). TenantProvisioningProcessorsetsonboardingStatus = PROVISIONING, recordsPROVISIONING_STARTED, callsprovision()(unchanged), then on success setsonboardingStatus = ACTIVE(provision()already flipsisActive), recordsPROVISIONING_COMPLETED, and consumes the branch invite (BranchInviteService.markAccepted, moved here fromSignupControllersince the controller no longer awaits completion). On permanent failure (all 3 attempts exhausted, via@OnQueueFailed()) setsonboardingStatus = FAILEDand recordsPROVISIONING_FAILEDwith the error message inmetadata.GET /signup/:tenantId/status(@Public()) — polled by the caller untilstatusisACTIVE(orFAILED). Unauthenticated by design, same reasoning asPOST /signupitself; excluded fromTenantMiddlewarealongside it inTenantModule(a caller may not be on the tenant’s own subdomain yet — e.g. a marketing site). Confirmed live this exclude needs a named path parameter (v1/signup/:tenantId/status), not a bare(.*)wildcard mid-path — this project’spath-to-regexpversion throwsPathErroron boot for that shape; only a suffix wildcard likev1/platform/(.*)is accepted.
Platform-admin tenant creation is synchronous. PlatformTenantService.createTenant() calls
ensurePendingTenant() + recordEvent('PLATFORM_ADMIN_INITIATED', PLATFORM_ADMIN, { actorId }), then calls
provision() directly and awaits it inline — no queue, no polling. On success it sets onboardingStatus = ACTIVE
and records PROVISIONING_COMPLETED; on failure it sets onboardingStatus = FAILED, records
PROVISIONING_FAILED with the error in metadata, and rethrows so the platform admin sees the real error
immediately instead of a silently-stuck PENDING row. This was deliberately reverted from an earlier async design:
unlike self-serve signup, this is a trusted, authenticated action by a platform admin, there’s no fraud-review gate
that would need to sit between “created” and “actually provisioned,” and CREATE SCHEMA + migrations + seeding is
fast enough that the admin can just wait for the response.
Platform-level audit trail (TenantOnboardingEvent, tenant_onboarding_events): distinct from
AuditLogService, which is tenant-scoped (lives in each church’s own schema, actor FKs to that tenant’s own
Member) and can’t record an event from before/independent of any tenant schema existing. A small, purpose-built,
public-schema table instead: tenant (FK, cascade), event (SIGNUP_INITIATED | PLATFORM_ADMIN_INITIATED | AWAITING_APPROVAL | APPROVED | PROVISIONING_STARTED | PROVISIONING_COMPLETED | PROVISIONING_FAILED), actorType
(SELF_SERVE | PLATFORM_ADMIN | SYSTEM), actorId (nullable — the platform admin’s id when actorType = PLATFORM_ADMIN), metadata (nullable jsonb). Written via TenantProvisioningService.recordEvent() — no separate
service, it’s a simple insert-only log. Viewable per-tenant via GET /platform/tenants/:id/onboarding-events
(TENANTS_READ permission). The platform-admin path never emits PROVISIONING_STARTED — there’s no meaningful gap
between “initiated” and “started” when both happen inline in the same request.
Response shape: POST /signup returns a PENDING (or AWAITING_APPROVAL, see below) tenant immediately (poll
GET /signup/:tenantId/status for completion). POST /platform/tenants returns the tenant already ACTIVE — same
shape GET /platform/tenants’ rows use, no polling needed.
Manual Approval Gate for Self-Serve Signups
PlatformSettingKey.SELF_SERVE_REQUIRES_APPROVAL (boolean, default off — see Platform Settings below) inserts a
review step between a cold self-serve POST /signup and real provisioning, to stop demo/test/abuse signups from
auto-provisioning unattended. When on:
SignupController.signup()still creates thePENDINGTenantrow and recordsSIGNUP_INITIATEDexactly as before, but — for a genuine cold signup only, see below — callsTenantProvisioningService.holdForApproval()instead of enqueueing the provisioning job. That setsonboardingStatus = AWAITING_APPROVAL, persists the signup detailsprovision()will eventually need onto the newTenant.pendingSignupParamsjsonb column (admin name/email, plan, branch-invite linkage — these normally only ever live transiently in the queue job payload, which doesn’t work here since approval could happen an unpredictable amount of time later), records anAWAITING_APPROVALonboarding event, and emails every active platform admin who holdsTENANTS_WRITE(viatenant-approval-needed.html, a new platform-level template alongsideplatform-admin-welcome.html— same “no tenant in CLS context” branding fallback) a link to the Tenants page.TenantMiddlewaretreatsAWAITING_APPROVALidentically toPENDING/PROVISIONING— a site visitor sees the same “still being set up” 503, not a different message; only the platform-admin console needs the distinct state.- A platform admin reviews the held signup from its detail panel in discuva-platform and either:
- Approves —
PATCH /platform/tenants/:id/approve(TENANTS_WRITE,PlatformTenantService.approveTenant): 404/409 unless the tenant is actuallyAWAITING_APPROVAL, reconstructs the provisioning job frompendingSignupParams, records anAPPROVEDevent (actorType: PLATFORM_ADMIN), and enqueues it via the sameTenantProvisioningService.enqueueProvisioning()both this andSignupControllercall — provisioning stays async even here, since this is still fundamentally a self-serve signup being released, not a platform admin directly creating one (POST /platform/tenantsstays instant and untouched by this gate entirely). - Rejects — reuses
DELETE /platform/tenants/:id(see “Deleting an invalid/incomplete signup” above), whose allowed-status set now includesAWAITING_APPROVALalongsidePENDING/FAILED.
- Approves —
A branch invite bypasses this gate even when the toggle is on — accepting an invite already required a parent
tenant’s own admin to generate the token, so it’s an invitation-only path already vetted once; gating it a second
time behind generic approval would be redundant friction on top of vetting that already happened. Only a cold,
anonymous signup (resolvedInvite unset in SignupController.signup()) is ever held.
Why TenantProvisioningService now owns TENANT_PROVISIONING_QUEUE/TENANT_PROVISIONING_JOB/
TenantProvisioningJobData (moved from tenant-provisioning.processor.ts, which now imports them back): both
SignupController and PlatformTenantService.approveTenant() need to construct and enqueue a provisioning job, and
the processor already imported TenantProvisioningService — defining the job-payload contract there too instead of
keeping it on the processor avoids a circular file import between the two.
Founder Welcome Email (FounderWelcomeEmailScheduler)
A personal, one-time note from Discuva’s founder, sent 1 day after a tenant first reaches ACTIVE — every
activation path (self-serve, platform-admin-created, an approved signup, a branch), not just self-serve. Deliberately
separate from the transactional tenant-welcome email (the set-password link, sent immediately at provisioning) —
this exists purely to feel human, not to drive an action.
Two new Tenant columns: activatedAt (set exactly once, at the same two call sites that set
onboardingStatus = ACTIVE and record PROVISIONING_COMPLETED — TenantProvisioningProcessor.handle() and
PlatformTenantService.createTenant() — see “Manual Approval Gate” above for why this can’t just be createdAt: a
signup that sat AWAITING_APPROVAL for days would otherwise fire the founder email almost immediately after
activation instead of a day after it) and founderWelcomeEmailSentAt (null until sent — the once-only guard; stays
null on a failed send so the next day’s sweep retries it, only set on actual success).
FounderWelcomeEmailScheduler.sendDueFounderWelcomeEmails() — @Cron('0 9 * * *'), daily (a day-granularity
threshold gets a daily check, not hourly — same reasoning AssignmentReminderScheduler/PledgeReminderScheduler
already establish for their own EVERY_DAY_AT_8AM crons). Deliberately not routed through
forEachActiveTenant() — that helper scans every active tenant on every run, which would mean re-checking every
tenant on the platform daily just to find the handful newly due; instead queries tenants directly for
onboardingStatus = ACTIVE AND isActive = true AND founderWelcomeEmailSentAt IS NULL AND activatedAt <= now() - 24h,
then enters each matching tenant’s schema one at a time via runInTenantContext() (the same primitive
forEachActiveTenant() itself is built on) to read that tenant’s earliest-created active Admin+Member — a raw
CLS-scoped tx.findOne(Admin, ...) read, not an injected Admin repository, mirroring
PlatformTenantService.impersonateTenant()'s identical pattern and for the identical reason (see
tenant-typeorm.module.ts’s comment on why a plain @InjectRepository() can never see a per-job tenant
transaction). The email itself is sent outside that tenant context (UtilityService.sendEmailWithTemplate,
template founder-welcome) so branding resolves to Discuva’s own identity via the same no-tenant-in-CLS fallback
platform-admin-welcome.html/tenant-approval-needed.html already rely on — this is Jeremiah writing as Discuva’s
founder, not a tenant-branded transactional email. A tenant with no admin found is skipped (logged, not marked
sent — shouldn’t happen for a genuinely ACTIVE tenant, but defensive); a per-tenant send failure is caught, logged,
and the loop continues to the next tenant, matching every other scheduler’s resilience convention in this codebase.
No reply-to. The template deliberately doesn’t invite a reply — there’s no replyTo mechanism anywhere in the
email pipeline (SendMailOptions has no such field, across all 5 providers), so promising one would be hollow.
Points instead to the tawk.to chat widget already live in discuva-admin’s dashboard (“the chat bubble in the corner
of your dashboard”) as the real, working support channel.
Role Elevation
The access token’s role is re-validated from the live database on every request via validateAccessToken. This means if
a member is promoted to WORKER, their existing token will reflect the new role on the next request after the DB is
updated.
Department Capabilities
Certain modules are gated by a department capability rather than a specific department name or a single free-form
key. This is a full replacement of the earlier Department.key-based system: key (a single free-form string,
validated against nothing but a preset-suggestion enum) has been removed entirely, in favor of capabilities — a
fixed, code-defined, multi-value list. The old system conflated “this department’s organizational label” with “what
it unlocks,” forcing an admin to type a magic string that had to exactly match hardcoded values scattered across both
the backend and discuva-member mobile, and could only ever grant one capability per department. Capabilities decouple these:
a department can be named anything, and separately be given any combination of capabilities via checkboxes in the
admin UI.
How it works:
- Each
Departmenthas acapabilities: DepartmentCapability[]column (text[], default{}).DepartmentCapability(src/department/enums/department-capability.enum.ts) is a fixed enum — six values today, each named after the action it unlocks rather than a department:MANAGE_SUNDAY_SCHOOL,MANAGE_CHILDREN_CHURCH,MANAGE_PRAYER_REQUESTS,MANAGE_EVANGELISM_CONVERTS,MANAGE_FOLLOW_UP,FRONT_DESK_OPERATIONS. A capability only belongs in this list if a real feature is gated on it — mirrors theKNOWN_MODULESpattern.CreateDepartmentDto/UpdateDepartmentDtovalidatecapabilitieswith@IsEnum(DepartmentCapability, { each: true }); unlike the oldkey, admins pick from a fixed checkbox list, not free text, and a single department can hold more than one capability at once. - A
WorkerProfilehas a primarydepartmentand an optionalsecondaryDepartment. A worker has a capability if either department’scapabilitiesarray includes it. DepartmentAccessService(src/department/service/department-access.service.ts, exported fromDepartmentModule) is the single shared implementation of this check —hasCapability(memberId, capability)(boolean, for composing with other conditions like “or is a pastor” or “or is the class teacher”) andassertHasCapability(memberId, capability, message?)(throwsForbiddenException). Used by 7 services —attendance,evangelism,sunday-school,prayer-request,service-session,children-church, andfollow-up— each calling it with its own capability and message; two of those services (evangelism,follow-up) also have an inline duplicate of the same check where they already have theWorkerProfileloaded and calling the service would mean a redundant query.GET /auth/mecomputes a flatcapabilities: DepartmentCapability[]field onMemberDto(@Transform, same pattern asclergy) — a deduped union of the primary and secondary department’s capabilities. discuva-member mobile checks this one field (profile?.capabilities?.includes("X")) instead of independently re-deriving the primary-or-secondary union at every call site.- HOD (head-of-department) assignment is always restricted to the worker’s primary department (unrelated to capabilities).
Sunday School access — a request passes if any of the following is true:
- Caller is a WORKER whose primary or secondary department has the
MANAGE_SUNDAY_SCHOOLcapability. - Caller is the appointed teacher of the specific Sunday School class being acted upon.
Admin-only SS routes (delete class/session) use AdminGuard + SUNDAY_SCHOOL_WRITE instead.
Children Church access — a request passes if any of the following is true:
- Caller is a WORKER whose primary or secondary department has the
MANAGE_CHILDREN_CHURCHcapability.
Admin-only CC routes (age group/class group CRUD, slot-level check-in report) use
AdminGuard + CHILDREN_CHURCH_WRITE/READ instead.
Migration note: 1790208000000-ReplaceDepartmentKeyWithCapabilities.ts backfills the 6 legacy key values that
had real behavior behind them (ADMIN, EVANGELISM, SUNDAY_SCHOOL, PRAYER, CHILDREN_CHURCH, FOLLOW_UP) into
their corresponding capability, then drops the key column. Any department whose key was one of the other preset
values (WORSHIP, USHERING, MEDIA, PROTOCOL, WELFARE, YOUTH, YOUNG_ADULTS) or a custom string had no real
behavior behind it and is simply dropped — those departments end up with capabilities: [].
5. Module Reference
Multi-Tenant Request Scoping
Every request except /v1/platform/*, /v1/signup, and the version-neutral /, docs, health routes goes
through TenantMiddleware: it resolves the tenant from the Host header’s subdomain (stripped of
APP_BASE_DOMAIN, e.g. church-alpha.example.com → church-alpha; localhost in dev, so *.localhost works with
no /etc/hosts changes), then wraps the entire rest of the request — guards, interceptors, and the handler — in one
DB transaction with SET LOCAL search_path set to that tenant’s schema. Full design in
docs/MULTI_TENANT_MIGRATION.md §4.3/§4.4.
Fallback resolution for a fixed, non-wildcard host (added 2026-08, extended to discuva-member 2026-08):
discuva-admin is deployed at a single admin.discuva.org origin shared by every tenant, not a per-tenant wildcard —
admin is in RESERVED_SUBDOMAINS (src/tenant/utility/extract-subdomain.ts) specifically so no tenant could
ever collide with it, but that also means extractSubdomain() always returns null there: there is no subdomain
in the Host header to strip. discuva-member has a real per-tenant wildcard for its own hosting
({tenant}.discuva.org, resolved the normal Host-header way, unchanged) but its API calls target the separate,
dedicated api.discuva.org host every other app calls directly — same problem, different reason: the Host header
TenantMiddleware sees on that call carries no subdomain either. When that happens, TenantMiddleware tries two
fallbacks, in order, before giving up with the same 404 Tenant not found as before:
- A verified JWT tenant claim. Every access/refresh token, both surfaces, embeds
tenantId/schemaNamein the payload at sign time (AuthService.generateTokens(), readingcls.get('tenantId')/cls.get('schemaName')— already correctly set for that request by whichever mechanism resolved it, Host header or this same fallback on the login request itself).TenantMiddlewarechecks theAuthorization: Bearerheader first — trying the access secret, thenREFRESH_JWT_SECRET, since a Bearer header can legitimately carry either token type (discuva-admin’s refresh flow sends its refresh token via an httpOnly cookie; discuva-member’s sends it via this same header instead, a pre-existingRefreshJwtStrategydesign, not something added for this) — then therefresh_tokenhttpOnly cookie (verified withREFRESH_JWT_SECRET) as a second fallback. Covers every authenticated request, including a bare/v1/auth/refreshcall from either app. This is genuinely safe against spoofing: the claim only exists inside a JWT whose signature already proves it came from this server, at a moment CLS already held the correct tenant — there’s no way for a client to write an arbitrarytenantIdinto a token it can’t forge the signature for. X-Tenant-Subdomainheader. discuva-admin sends it on every pre-auth request where no token exists yet:POST /v1/auth/admin-login(needs to know which tenant’sMember/Admintables to check credentials against before it can issue anything), andPOST /v1/auth/forgot-password/reset-password(same reasoning —PasswordResetOtpis tenant-schema-scoped too, and both the “Forgot password” flow on the login screen and the first-time/set-passwordflow reached from a welcome email are equally pre-auth). discuva-member sends it on every request toapi.discuva.org(derived from its own Host header viagetCurrentTenantSubdomain(),utils/tenant/api-base-url.ts), not just pre-auth ones — harmless to include always, and it’s whatapp/manifest.ts’s server-side, pre-authGET /tenant/infocall for PWA branding relies on, since no JWT exists there yet either. This header is not cryptographically trusted the way the JWT claim is — it’s exactly as trustworthy as a user typing a workspace URL: a wrong or malicious value just resolves to the wrong (or a nonexistent) tenant’s schema, where the supplied email/OTP/session simply won’t match any real row, so it can never grant access to anything, only ever fail against the wrong place. On an authenticated discuva-member request it’s redundant with (and always loses to) the JWT claim above — sent anyway for the handful of pre-auth calls that need it, and it’s simpler to attach unconditionally than to special-case which requests do.
Both fallbacks are skipped entirely — not even attempted — whenever the Host header itself already resolved a subdomain, so discuva-member’s own hosting (as opposed to its outgoing API calls) is completely unaffected; this exists purely to make a fixed, non-wildcard destination host work for the two kinds of traffic that need one.
Fixed bug: the refresh_token cookie’s Path silently broke fallback #1 for discuva-admin on every route except
the refresh endpoint itself. The cookie used to be scoped to path: '/v1/auth/refresh' (AuthController’s
REFRESH_COOKIE_PATH) — meaning the browser only ever attached it to that one route, even though
TenantMiddleware’s fallback reads that same cookie on every route. In practice: discuva-admin’s access token
lives only in an in-memory JS variable, so once it expired (a backgrounded tab, mobile tab suspension) a normal
request’s Authorization header failed verification, the refresh cookie wasn’t sent (wrong path) so the fallback
found nothing, and the middleware threw a 404 “Tenant not found” — before any guard ran, so the existing
401-triggered refresh interceptor in discuva-admin’s axios client never saw it and never re-authenticated. The
session stayed stuck until a hard reload forced a direct call to /v1/auth/refresh, the one path where the cookie
was actually valid. Fixed by widening REFRESH_COOKIE_PATH to /v1 (covers the whole API, still excludes
anything outside it) so the fallback works on every route; an expired access token now correctly falls through to
an ordinary 401 from the auth guard, which the pre-existing reactive refresh flow already handles.
Distinct error responses per tenant state, not a single generic 404: the tenant lookup is findOneBy({ subdomain }), deliberately not filtered by isActive, so a row that exists but isn’t (yet, or anymore) usable gets
a response that actually explains why, using onboardingStatus (see “Async Tenant Provisioning + Onboarding State
Machine” above) to disambiguate:
- No
Tenantrow at all for the subdomain →404 Tenant not found(unchanged). onboardingStatusisPENDING/PROVISIONING→503, “This workspace is still being set up. Please check back in a moment.” — this is the common case for a subdomain hit moments after signup, before the queue has finished.onboardingStatusisFAILED→503, “There was a problem setting up this workspace. Please contact support.” — deliberately no technical detail in the public response;GET /platform/tenants/:id/onboarding-eventsis where that lives.onboardingStatusisACTIVEbutisActiveisfalse→403, “This account has been suspended. Please contact support.”onboardingStatusnever reverts onceACTIVE, so this combination only ever meansPlatformTenantService.suspendTenantwas used — a materially different situation from “still provisioning” that a flatisActivecheck couldn’t previously tell apart (both 404’d identically before this).onboardingStatus === ACTIVE && isActive === true→ proceeds normally, as before.
For any new module with tenant-owned tables: register entities with TenantTypeOrmModule.forFeature([...])
(src/tenant/utility/tenant-typeorm.module.ts), not TypeOrmModule.forFeature([...]). Plain TypeOrmModule
repositories never see the tenant transaction regardless of request scoping — this is a @nestjs-cls/transactional
limitation, not a bug to work around per-call. TenantTypeOrmModule is a drop-in replacement using the same DI
token, so @InjectRepository(Entity) call sites in services need no changes. Only genuinely global, public-only
tables (Tenant, PlatformAdmin, Plan/Subscription, and similar control-plane entities) should keep plain
TypeOrmModule.forFeature().
Letting the database scale to zero (SchedulerGateService, added 2026-10-01): the frequent jobs — programme
auto-start (5 min), absence marking (5 min), rental status (10 min), event reminders (15 min), class session reminders
(hourly) — used to open a transaction in every church on every tick, so the Neon database never stayed idle for the
5 minutes it needs to scale to zero. They now use SchedulerGateService.forEachDueTenant(job, txHost, logger, fn)
(src/tenant/scheduler-gate/): fn returns when that church next needs the job (next auto-start time / event end /
booking start or end / reminder fire_at / class reminder threshold or session start; “now” while something due is
still waiting), stored in Redis as global:scheduler:next-due:{job}:{tenantId}. Later ticks skip that church — no
transaction, no query — until then. Safety rails: never sleeps past the top of the next hour (so a missed wake-up delays
work by at most an hour, and all jobs wake the database together); SchedulerGateSubscriber (TypeORM subscriber on the
shared DataSource) clears the marker on any insert/update/delete of the tables a job reads (JOBS_BY_ENTITY: service
programmes/sessions/slots/configs, events, event reminders, rental bookings, church classes, class sessions, church
settings), whichever code path writes; a write during a run (global:scheduler:woken:*) stops that run from sleeping;
a failed run or a Redis error means the job runs as before. The active-church list is cached in Redis for 10 minutes
(global:scheduler:active-tenants, cleared when a Tenant row changes). Simulated over days of random schedules, every
item fires at the same tick as without the gate; a quiet day goes from 288 runs per 5-minute job to 24. Daily jobs are
unchanged. The connection pool’s Fly’s 30-second /health check no longer queries the database (/health/deep does). DATABASE_POOL_MIN now defaults to 0 (idle connections close after 30s) and
connectionTimeoutMillis is 10s so the first query after the database wakes doesn’t fail.
Scheduler tenant iteration (forEachActiveTenant): @Cron()-decorated methods run with no CLS context at all —
there’s no HTTP request for TenantMiddleware to hook into. A tenant-scoped repository called from inside a
scheduler with no CLS context silently falls back to the plain public-search-path manager instead of throwing, so
without doing anything about it a scheduler processes whatever stale/orphaned rows happen to sit in public, not
any real tenant’s data. Every scheduler that touches tenant-scoped data fetches every active Tenant and re-enters
that tenant’s context once per tenant via the shared helper forEachActiveTenant(tenantRepo, cls, txHost, logger, fn) (src/tenant/utility/for-each-active-tenant.ts), which wraps the existing runInTenantContext() helper (the
same one EmailProcessor already used) in a fetch-loop-catch: fn runs once per active tenant inside that tenant’s
cls.runWith(...) + SET LOCAL search_path transaction, and one tenant throwing is caught and logged without
stopping the rest of the batch. Any distributed Redis lock a scheduler already had (e.g. lock:pledge-reminders)
still wraps the whole @Cron method across all tenants, unchanged — it guards against two app instances racing,
not tenants racing each other. A service that opens its own dataSource.transaction() inside a scheduler needs
extra care: a fresh top-level transaction doesn’t inherit the outer SET LOCAL search_path (likely a different
pooled connection) and would silently write to the wrong schema — such call sites are rewritten to use the ambient
this.txHost.tx manager instead (see AttendanceService.markAbsentees() and RecurringEntryScheduler, the latter
wrapping each recurring entry in its own Postgres SAVEPOINT so one entry’s failure doesn’t abort the whole
tenant’s batch). BranchRollupScheduler predates this helper and hand-rolls the identical fetch-loop pattern
itself; YoutubeSubscriptionScheduler and SubscriptionLapseScheduler’s top-level query are exempt because their
data is genuinely control-plane (public-schema), not tenant-owned.
CORS origin validation (createCorsOriginValidator): wildcard-subdomain tenancy means the set of valid frontend
origins is unbounded — a new tenant’s subdomain is valid to call the API the moment it’s provisioned, so a static
CORS_ORIGINS allowlist can never enumerate them all (and previously didn’t try to — it silently rejected every
tenant subdomain origin that wasn’t hand-added to the list, a real bug, not just a gap). src/main.ts’s
app.enableCors(), ServiceSessionGateway, and GameSessionGateway all now validate the incoming Origin header
dynamically via the shared createCorsOriginValidator() (src/tenant/utility/cors-origin-validator.ts): allow if
the origin’s hostname equals APP_BASE_DOMAIN or ends in .${APP_BASE_DOMAIN} — the exact same suffix logic
extractSubdomain() uses for tenant resolution, so “is this origin allowed to call the API” and “does this host
resolve to a tenant” can never disagree — with a small explicit CORS_ORIGINS allowlist checked first for origins
that don’t fit the pattern (a separate marketing site, API docs, internal ops tooling). Requests with no Origin
header (curl, server-to-server calls, mobile apps) are always allowed, matching the previous behavior. Custom
domains (a tenant’s own domain, e.g. giving.theirchurch.org) don’t end in APP_BASE_DOMAIN and are rejected by
this check today — deliberately deferred, same as tenant_domains itself (see Custom Domains note below); adding
support means a second branch in createCorsOriginValidator() checking a cached set of verified custom domains
before falling through to reject (must stay cached, not a live DB query — this runs on every request/connection).
Custom domains (deferred, not built): docs/MULTI_TENANT_MIGRATION.md earmarks a future tenant_domains table
for letting a church map their own domain (giving.theirchurch.org) to their tenant, instead of only
their-church.<APP_BASE_DOMAIN>. Not built — subdomain routing is sufficient for now. When it is: (1) resolve the
custom domain to the tenant’s canonical subdomain via a new lightweight public endpoint, cached client-side, so
discuva-member’s getCurrentTenantSubdomain() (utils/tenant/api-base-url.ts) can resolve it to the same
subdomain it already sends as X-Tenant-Subdomain today — no changes needed to CLS/SET LOCAL search_path/
tenant-scoped repos, all of that stays subdomain-keyed; (2) a domain must be
DNS-verified (TXT record token, or requiring it already point at this infrastructure) before being trusted — a row
in the table must never be sufficient on its own, since nothing stops a tenant from entering a domain they don’t
control; (3) TLS is an infrastructure decision independent of this codebase — a single wildcard cert covers every
subdomain automatically, but each custom domain needs its own certificate (e.g. a reverse proxy that automates
ACME/Let’s Encrypt on demand, or requiring the tenant sit behind a proxy like Cloudflare that terminates TLS for
them) — nothing here provisions that.
Auth Module
Routes: POST /auth/signup, POST /auth/login, POST /auth/admin-login, POST /auth/refresh,
POST /auth/logout, GET /auth/me, POST /auth/change-password, POST /auth/email-change/request,
POST /auth/email-change/confirm, POST /auth/forgot-password,
POST /auth/reset-password, POST /auth/device-reset/request, POST /auth/device-reset/verify,
POST /auth/webauthn/login/options, POST /auth/webauthn/login/verify,
POST /auth/webauthn/register/options, POST /auth/webauthn/register/verify,
GET /auth/webauthn/credentials, DELETE /auth/webauthn/credentials/:id
POST /auth/email-change/* require an authenticated session (member or worker) — see Self-Service Email Change Flow
above for the full request/confirm sequence.
Route separation: POST /auth/login is for the mobile app (members & workers) and enforces device lock —
deviceId is required. POST /auth/admin-login is for the web admin portal — it verifies that the caller has an
active Admin record and has no device check. Both routes use the same Passport LocalAuthGuard for credential
validation.
WebAuthn / biometric login (WebauthnService, src/auth/service/webauthn.service.ts) — mobile-app-only
alternative to password login using the browser’s platform authenticator (Face ID / Touch ID / Android fingerprint /
Windows Hello), built on @simplewebauthn/server. member_webauthn_credentials (tenant schema, migrated in
src/migrations/tenant/) holds one row per registered device/authenticator — deliberately no uniqueness constraint
on member_id, unlike member_sessions’ one-row-per-surface rule: a member can register several devices
independently.
- Usernameless (discoverable/resident credentials) — registration sets
residentKey: 'required', soPOST /auth/webauthn/login/optionsneeds no email and returns generic options withallowCredentialsomitted; the browser/OS itself resolves which registered credential to use and prompts biometrics directly. The resolvedmemberIdonly becomes known oncePOST /auth/webauthn/login/verifysucceeds (matched by the assertion’scredentialIdagainst the stored row), at which pointAuthService.loginWithWebauthn(memberId)issues tokens via the exact samegenerateTokens()used by password login — no separate token-issuance path exists. loginWithWebauthnruns the same active/status checksvalidateMember()applies for password login (INACTIVEstatus, revoked/suspended worker), but deliberately does not applylogin()'s single-deviceIdlock — that lock’s threat model (a shared/leaked password) doesn’t apply to a hardware-bound private key that never leaves the device, and the entire point of allowing several WebAuthn credentials per member is several trusted devices logged in independently.- RP ID is the platform’s fixed
APP_BASE_DOMAIN, never the tenant subdomain — WebAuthn allows an RP ID that’s a registrable-domain suffix of the current origin, so a credential registered onchurch-a.<base>still validates when asserted fromchurch-a.<base>later (the request’s actualOriginheader is still checked exactly viaexpectedOriginon every verify call). RP name (shown in the OS-level prompt) is resolved per-request from the current tenant’s ownTenant.namewhere available, falling back toPRODUCT_NAME— same personalization as every other tenant-branded surface in this app. - Challenges are ephemeral Redis entries (
CacheService, 5 min TTL, keywebauthn_challenge:<memberId-or-random-challengeId>) — never persisted to Postgres, single-use (deleted immediately after a verify attempt, success or failure). - Registration (
POST /auth/webauthn/register/options//verify) requires an existing authenticated session (JwtAuthGuard, same as any other/auth/*account-management route) — a member enrolls a new device from within an already-logged-in session, typically from Account settings. - Device management:
GET /auth/webauthn/credentialsreturns{ id, deviceName, createdAt, lastUsedAt }per row — nevercredentialId/publicKey, which the client has no use for.deviceNameis derived from the registering request’sUser-Agentat enrollment time (“iPhone”, “Android device”, “Mac”, “Windows PC” — a label only, never used for anything security-relevant).DELETE /auth/webauthn/credentials/:idis scoped to(id, memberId)—404if the row doesn’t belong to the caller. Both credential registration and removal are audit-logged (MEMBER_WEBAUTHN_CREDENTIAL_REGISTERED/_REMOVED), and a successful biometric login logsMEMBER_LOGIN_WEBAUTHN(distinct fromMEMBER_LOGIN, so the audit trail can tell login method apart). - Clone/replay protection: each credential’s signature
countermust strictly increase on every successful authentication (@simplewebauthn/server’sverifyAuthenticationResponseenforces this) — a same-or-lower counter fails verification, the standard signal an authenticator’s key material was cloned. - Interaction with Self-Service Device Reset: because WebAuthn logins never check
deviceId,POST /auth/device-reset/verifyalso callsWebauthnService.revokeAllCredentials(memberId)— every registered credential is deleted, not just the password-login device lock. See the Device Reset section above for why (a lost device’s biometric key would otherwise survive a reset intended to lock it out).
Member Module
Manages the universal identity. Admin portal routes (list members, promote/revoke workers, change status, reset
passwords) are now guarded by AdminGuard + the appropriate MEMBERS_READ or MEMBERS_WRITE permission.
Routes prefix: /members
Admin-created members: POST /members (AdminGuard + MEMBERS_WRITE) lets an admin create a plain MEMBER
account directly — for members without a phone/email habit, or who otherwise can’t complete self-signup. Body is
SignupDto (same DTO as POST /auth/signup). MemberService.createByAdmin shares its implementation with
signup() via a private createMemberRecord helper: same temp password generation, changedPassword: false
(forces the change-password flow on first login), and welcome-member email with the temp password/login URL. The
only difference is the audit action — MEMBER_CREATED_BY_ADMIN (with the admin as actorId) instead of
MEMBER_SIGNED_UP. Promoting the new member to a worker afterwards is a separate step — use the existing
POST /members/:id/promote.
Self-service profile edit: PATCH /members/me (JwtAuthGuard only, no admin) lets a member/worker update their
own firstname, lastname, phoneNumber, gender, birthDay, birthMonth, birthYear, maritalStatus and
church journey — dateJoinedChurch (YYYY-MM-DD), yearBornAgain, yearBaptized (YYYY; null clears),
baptizedWithHolyGhost (UpdateMyProfileDto, all fields optional). Excludes email (handled by the OTP-gated
email-change flow — see Self-Service Email Change Flow). Admins can still edit the same fields via PATCH /members/:id.
Serve interest: POST /members/me/serve-interest / DELETE /members/me/serve-interest (JwtAuthGuard) set or
clear serveInterestAt — the in-app “I’d like to serve” request that replaced signup’s workforce step. Idempotent;
a WORKER asking returns 400. Audited as MEMBER_SERVE_INTEREST_ADDED / MEMBER_SERVE_INTEREST_WITHDRAWN.
Admins find these members with GET /members?wantsToServe=true (active members only). Cleared by: the member
withdrawing, an admin dismissing it (DELETE /members/:id/serve-interest, MEMBERS_WRITE, audited as
MEMBER_SERVE_INTEREST_DISMISSED — the member can ask again), promotion (promoteToWorker / bulkPromoteToWorker, in
the same transaction as the role change), or deactivation (PATCH /members/:id/status → INACTIVE).
Clergy designation: four AdminGuard + MEMBERS_WRITE routes manage the optional Clergy relation on a member
(same permission as promote-to-worker — no separate permission was introduced):
POST /members/:id/clergy— body{ clergyTitleId: string (uuid) }— assigns the designation;409 Conflictif the member is already clergy,404ifclergyTitleIddoesn’t match aClergyTitle.PATCH /members/:id/clergy— body{ clergyTitleId: string (uuid) }— changes the title;404if the member is not clergy, or ifclergyTitleIddoesn’t match aClergyTitle.DELETE /members/:id/clergy— removes the designation;404if the member is not clergy. Returns204.PATCH /members/:id/clergy/review-access— body{ canReviewFeedback: boolean }— grants/revokes Pastor Feedback review access, independent of title (see Clergy above and Pastor Feedback Module below);404if the member is not clergy.
See ClergyTitle above for the tenant-configurable title catalog these routes reference (GET /clergy-titles to
populate a picker with the tenant’s own titles).
clergy: { title: {id, name}, canReviewFeedback: boolean } | null is surfaced on MemberDto (GET /auth/me,
GET /members/:id, GET /members, GET /members/workers), computed from the clergy relation.
Spouse link (POST /members/:id/spouse { spouseId }, DELETE /members/:id/spouse, both AdminGuard +
MEMBERS_WRITE): a symmetric “married to” link between two Member rows — the only family-relationship concept in
the system today (a ChildProfile is not a Member/FirstTimer and has its own ChildGuardian links instead, see
§Children Church Module). Modeled as a plain self-referencing FK (members.spouse_id, nullable, ON DELETE SET NULL, migration AddMemberSpouse) rather than a TypeORM self-referential OneToOne (the inverse side has no
distinct property to map to) or a join table (unnecessary for a 1:1 pair with no extra fields yet). Both rows are
always written in one transaction (MemberService.linkSpouse()/unlinkSpouse()) so the link can never end up
one-sided — member.spouse and spouse.spouse are always mirror images of each other. linkSpouse rejects (not
silently overwrites) if either side already has a spouse — the caller must unlinkSpouse() first — and rejects
linking a member to themselves. Audit-logged as MEMBER_SPOUSE_LINKED/MEMBER_SPOUSE_UNLINKED. spouse: { id, firstname, lastname, photoUrl } | null is surfaced on MemberDto (GET /auth/me, GET /members/:id) via a new
SpouseRefDto, same shallow-ref pattern as clergy. Not loaded on the paginated GET /members/GET /members/workers list routes — an extra join on every row of a frequently-paginated endpoint for a field those
views don’t render.
Admin UI lives on the Members list’s member-detail panel (app/members/page.tsx), as its own “Spouse” card next to
the “Clergy” card — link/search/unlink actions, same place clergy assignment already lives. Deliberately not on
the Member Journey / timeline page (app/members/[id]/timeline/page.tsx): that page is a history feed (first
visit, became a worker, training milestones, visit counts), and a spouse link is a static profile fact, not an
event — it was there in an earlier pass and got moved once that mismatch was pointed out.
Deliberately not self-service: only AdminGuard routes can set/clear the link, there is no member-facing
POST/DELETE equivalent. A member can see their own linked spouse (discuva-member’s account page reads spouse
off GET /auth/me) but cannot set or change it themselves — the same trust model the rest of the member-identity
surface already uses (department assignment, clergy designation, worker promotion are all admin-verified, not
self-declared). Letting a member link themselves to anyone with no confirmation from the other side would let one
member falsely claim to be married to another and see fields depending on that in the future; a mutual-consent
request flow was considered and deferred rather than built as a first pass.
Member timeline / “Member Journey” (admin UI label; renamed from “Digital Footprint” — church-facing language,
not tech jargon): GET /members/:id/timeline (AdminGuard + MEMBERS_READ) returns
{ events: MemberTimelineEvent[], serviceVisitCount: number, sundaySchoolVisitCount: number, isTraineeNow: boolean, childrenChurchDropOffs: number }.
events is a chronologically sorted
{ type, title, description, occurredAt }[] — the church-facing narrative of a member’s life in the system: first
visit → repeat visits → became a member → became a worker → started/completed training → department/clergy/status
changes. MemberTimelineService (src/member/service/member-timeline.service.ts) builds events from two sources,
not a dedicated history table:
Table of Contents
- System Overview
- Architecture
- Data Models
- Authentication & Authorization
- Module Reference
- API Endpoints Quick Reference
- Check-In Flow
- Automated Absence Marking
- Role & Permission Matrix
- Environment Variables
- Enum Reference
1. System Overview
A NestJS REST API that manages church membership, service attendance, workforce scheduling, class enrolment, Sunday School sessions, Children Church security check-in, internal announcements, tithe records, internal finance requests, and prayer meeting roster management for a local church.
Core design principles:
- Every church member has one account. Workers are members with an optional
WorkerProfileattached. - A single JWT login endpoint serves all member roles: MEMBER and WORKER. Admin portal access is controlled separately
via the
adminstable. - There are two distinct frontends: a mobile app for members and workers, and an admin web portal managed via the Admin RBAC system.
- Attendance is tracked per Event, not per slot. One event can have multiple slots but each member gets exactly one attendance record per event.
- Members are PRESENT or ABSENT. Workers can also be LATE (arrived after threshold) or ON_LEAVE (approved leave covering the event date). ON_LEAVE is neutral — it neither contributes to nor breaks the attendance streak.
- Absentees are marked automatically by a background cron job, not by user action.
- Sunday School tracks session-based attendance for permanent class assignments. Both teachers and enrolled students can mark attendance; self-mark requires an open window set by staff.
- Children Church provides a full security check-in/check-out system for 1000+ children, with per-session 6-character pickup codes, multiple guardians per child, automatic age-group assignment by date of birth, and pickup email notifications to guardians.
- Follow-Up tracks first-time visitors and online non-responders. A FollowUpTask is auto-created on every first-timer registration and assigned to a FOLLOW_UP-department worker via round-robin (fewest open tasks wins). After every event, thank-you emails are sent to all attendees and online-confirm requests are sent to absent members if
onlineAttendanceEnabledis set. Members who don’t confirm online attendance withinONLINE_CHECKIN_WINDOW_HOURSget a follow-up task.
2. Architecture
src/
├── auth/ Single login, JWT strategy, refresh tokens
├── member/ Universal identity: Member + WorkerProfile
├── event/ Event + ServiceSlot + EventConfig
├── venue/ Named, reusable venue entities (lat/lon)
├── attendance/ Check-in, history, leaderboard, cron job
├── department/ Departments + leads
├── request-leave/ Worker leave requests
├── classes/ ChurchClass + ClassEnrollment
├── announcement/ Announcements with audience targeting
├── birthday/ Birthday greetings, wish wall (BirthdayWish entity)
├── notes/ Pastoral notes (naming, dedication, marriage)
├── dashboard/ Aggregated dashboards per role
├── sunday-school/ Session-based SS classes, members, sessions, attendance
├── children-church/ Age groups, class groups, child profiles, guardians, check-in/out
├── admin/ Admin RBAC: AdminRole + Admin entities, AdminGuard, seed (@Global module)
├── tithe/ Batch tithe upload (Excel), queue-based processing, dispute resolution, member PDF statements
├── finance-request/ Department expense requests lifecycle (submit → approve/reject → proof)
├── follow-up/ First-timer registration, follow-up task management, post-event email jobs, online attendance
├── service-programme/ Service programme authoring, live session control, analytics, PDF reports
├── service-headcount/ Physical attendance headcounts per service slot, trends by period
├── prayer/ Prayer meeting roster: schedule config, day configs, rules, self-selection, auto-assignment, reminders
└── utility/ Email queue, cache, hashing, pagination, email delivery log, Cloudinary file uploads, PDF generation
Stack: NestJS · TypeORM · PostgreSQL · Redis · Bull · ioredis · Argon2 · Passport (JWT + Local) · class-validator · nestjs-schedule · @nestjs/throttler · Handlebars · DOMPurify · ExcelJS · PDFKit · Cloudinary · Nodemailer (Gmail SMTP) · Resend SDK · libphonenumber-js
3. Data Models
Member
The universal identity for every person in the system.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| firstname, lastname | string | |
| string | Unique | |
| password | string | Argon2 hashed |
| changedPassword | boolean | false on signup and admin password reset; set to true after first change |
| deviceId | string | null | Mobile device fingerprint registered on first login; null until first mobile login or after admin purge |
| role | MemberRoleEnum | MEMBER | WORKER (no ADMIN role — admin access is a separate entity) |
| status | MemberStatusEnum | ACTIVE | INACTIVE |
| gender | GenderEnum | Optional |
| birthDay | smallint | null | Day of birth (1–31); optional |
| birthMonth | smallint | null | Month of birth (1–12); optional |
| birthYear | smallint | null | Year of birth (1900–2100); optional — may be omitted when unknown |
| maritalStatus | MaritalStatusEnum | Optional |
| yearBornAgain | Date | Stored as Jan 1 of given year |
| yearBaptized | Date | Optional |
| baptizedWithHolyGhost | boolean | Optional |
| dateJoinedChurch | Date (date only) | Optional; full YYYY-MM-DD date, stored in date_joined_church column |
| serveInterestAt | Date | null | When the member asked to serve in the workforce (POST /members/me/serve-interest, or joinWorkforce: true at signup); cleared on withdraw, admin dismissal, promotion to worker, or deactivation. Tenant migration AddMemberServeInterest |
| photoUrl | string | null | Cloudinary secure_url of the member’s self-uploaded profile picture. null until first upload. |
| photoPublicId | string | null | Internal — Cloudinary public_id, used to delete the old asset on replace/remove. Not exposed on MemberDto. |
| workerProfile | WorkerProfile | OneToOne, null for plain members |
| clergy | Clergy | null | OneToOne, null unless the member carries a clergy designation — see Clergy table below |
| attendances | Attendance[] | OneToMany |
| enrollments | ClassEnrollment[] | OneToMany |
Profile picture: self-service via POST/DELETE members/me/photo (JwtAuthGuard), uploaded to Cloudinary folder profile-pictures (3MB limit, image mimetypes only). Replacing a photo uploads the new one first, saves it, then deletes the previous Cloudinary asset by photoPublicId (fire-and-forget). Admins can also clear a member’s photo via DELETE members/:id/photo (AdminGuard + MEMBERS_WRITE) for moderation. GET /birthday/today’s BirthdayCelebrant shape also carries photoUrl, alongside the existing role/department/clergyTitleName disambiguation for same-named celebrants (see Birthday Module).
WorkerProfile
Created when a member is promoted to WORKER. Never deleted by any revocation path — revokeWorker and demoteTraineeToMember both deactivate the row (status = INACTIVE) rather than removing it, so a member’s worker history (department, profession, completedSOD/completedBibleCollege, isTrainee) survives and is picked back up if they’re later re-promoted.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| member | Member | OneToOne |
| department | Department | ManyToOne — primary department |
| secondaryDepartment | Department | null | ManyToOne, nullable — secondary department; HOD/D-HOD can be assigned from primary OR secondary department |
| status | WorkerStatusEnum | ACTIVE | INACTIVE |
| profession | string | Optional |
| yearJoinedWorkforce | Date | Optional |
| completedSOD | boolean | School of Disciples |
| completedBibleCollege | boolean | |
| isTrainee | boolean | Default false, indexed (IDX_worker_profiles_is_trainee). Marks a worker as still in training/probation — has full worker access (role stays WORKER, RolesGuard only checks role) but is flagged in the UI (mobile “Training” badge, admin “Trainee” badge). Toggled via PATCH members/:id/worker-profile; a real flip (not just the field being present with its existing value) is separately audit-logged as WORKER_TRAINEE_STATUS_CHANGED (metadata: { isTrainee, departmentId }) alongside the always-fired, non-milestone WORKER_PROFILE_UPDATED — this is what lets the digital-footprint timeline show a clean “Started Training”/“Completed Training” entry instead of the generic (and deliberately timeline-excluded) profile-update action. |
Deactivation (revokeWorker / demoteTraineeToMember) — shared, non-destructive: both go through a private deactivateWorkerAccess() helper that removes DepartmentLead rows and any Sunday School teacher assignment (no cascade on those FKs), sets workerProfile.status = INACTIVE, and resets member.role = MEMBER. Access is fully revoked immediately — RolesGuard does an exact match on role alone, so a MEMBER-role account can’t reach worker routes regardless of what its (inactive) WorkerProfile looks like. The two differ only in guard + what they touch on isTrainee:
revokeWorker— any active worker,POST members/:id/revoke-worker. LeavesisTraineeuntouched (so reinstatement resumes exactly as they left off, trainee or not).demoteTraineeToMember—isTrainee = trueprofiles only (400 otherwise — “use revoke-worker instead”),POST members/:id/demote-trainee. Explicitly clearsisTrainee, since ending trainee status is the point of this action.
Both use AdminGuard + MEMBERS_WRITE.
Reinstatement (promoteToWorker): now checks workerProfile?.status === ACTIVE (not mere existence) before rejecting with “already registered as a worker” — a member with an INACTIVE profile is eligible again. buildOrReactivateWorkerProfile() reuses the existing row instead of creating a new one: department and status are always set from the call, but profession/yearJoinedWorkforce are only overwritten if explicitly supplied this time (otherwise the prior values are kept), and completedSOD/completedBibleCollege/isTrainee are never touched by this path at all — they simply carry over. Audit-logged as WORKER_REINSTATED (vs WORKER_PROMOTED for a genuinely new profile) so the trail distinguishes the two. bulkPromoteToWorker uses the same helper and ACTIVE-only guard for consistency.
Login/refresh gating (auth.service.ts): the “worker account suspended” check is scoped to member.role === WORKER — it used to fire whenever any workerProfile existed with a non-ACTIVE status, which would have wrongly blocked login for a plain MEMBER carrying a leftover INACTIVE profile from a prior revoke/demotion. Applied identically in validateMember, validateRefreshToken, and its rotated-token-replay path.
Clergy
A clergy designation on a member (renamed from “Pastor” 2026-08 — the container was still called “Pastor”
everywhere even though the whole point of the title catalog is that a tenant isn’t locked into Pentecostal/
Protestant terms; “Clergy” is the denomination-neutral standard term for ordained/formal ministry office).
Independent of WorkerProfile/Department — a clergy member may have no department (e.g. a Lead Pastor) or may
separately also be an HOD. At most one row per member (OneToOne on member).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| member | Member | OneToOne, onDelete: CASCADE |
| title | ClergyTitle | ManyToOne, onDelete: RESTRICT — see ClergyTitle below |
| canReviewFeedback | boolean | Default true. Independent of title — holding a title (a promotion/recognition) does NOT by itself grant the ability to see and respond to every department’s Pastor Feedback reports. Set explicitly via PATCH /members/:id/clergy/review-access, never as a side effect of a title change. See Pastor Feedback Module below. |
Managed via POST/PATCH/DELETE /members/:id/clergy (see Member Module). Surfaced on MemberDto as
clergy: { title: {id, name}, canReviewFeedback: boolean } | null, computed from the clergy relation.
Legacy type column, finally dropped (DropLegacyClergyType1798268400000). The original pastors table
(pre-ClergyTitle) had type character varying NOT NULL — a closed 3-value enum (LEAD/PARISH/ASSOCIATE).
AddClergyTitles1792382400000 replaced it with the clergy_title_id FK and backfilled type, but its own
comment deferred actually dropping the column to “a later, separate migration once the new code has baked with
no incidents” — that migration was never written. The Clergy entity had already dropped type as a property
entirely, so MemberService.assignClergy’s insert ({member, title}, no type) had no way to know the column
still existed — every new clergy assignment failed in production with null value in column "type" ... violates not-null constraint, since the column had no default. Confirmed no code anywhere (backend or either frontend)
still reads or writes clergy.type before dropping it for real.
ClergyTitle
Tenant-configurable clergy title catalog (added 2026-08, replacing the old hardcoded PastorTypeEnum). A tenant
defines its own titles instead of being locked into Pentecostal/Protestant terms like “Lead Pastor” — a Catholic
tenant can use Priest/Bishop/Deacon, a Methodist tenant Minister/Elder/District Superintendent, etc. Every
existing tenant was seeded with the 3 legacy labels (“Lead Pastor”/“Parish Pastor”/“Associate Pastor”) at migration
time so nothing broke on rollout; tenants are free to rename/delete/add from there.
| Field | Notes |
|---|---|
| id | UUID PK |
| name | Unique, max 40 characters |
| description | Nullable |
| clergy | OneToMany → Clergy |
Same CRUD shape as Department (src/clergy-title/, mirrors src/department/ structurally): create/update
enforce name uniqueness, delete is blocked (400) if any Clergy row still references the title — the DB-level
backstop is clergy.clergy_title_id’s onDelete: RESTRICT. GET /clergy-titles/GET /clergy-titles/:id are
public (mirrors GET /departments); POST/PATCH/DELETE reuse AdminGuard + MEMBERS_WRITE rather than a new
permission pair — same precedent already established for /members/:id/clergy itself.
name’s 40-char cap (added 2026-08) exists to keep the title from distorting the small badge UI it renders in
(the member detail panel and the member’s own account-page header, both flex-wrap pill rows) — not an arbitrary
DB constraint. Enforced via @MaxLength(40) on CreateClergyTitleDto. UpdateClergyTitleDto is a real class
extending PartialType(CreateClergyTitleDto), not the type X = Partial<Y> alias pattern used elsewhere in this
codebase — the latter compiles to Object for reflection purposes, so main.ts’s global ValidationPipe silently
skips validating it entirely (confirmed by testing: no whitelist stripping, no @MaxLength enforcement) since it
can’t resolve a real class to instantiate against. PartialType was needed here specifically so PATCH /clergy-titles/:id enforces the same cap as POST does, not just create.
Routes (src/clergy-title/controller/clergy-title.controller.ts):
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /clergy-titles | Public | Full catalog, ordered by createdAt DESC |
| GET | /clergy-titles/:id | Public | Single title |
| POST | /clergy-titles | AdminGuard (MEMBERS_WRITE) | { name, description? }; 400 if name already exists |
| PATCH | /clergy-titles/:id | AdminGuard (MEMBERS_WRITE) | Partial update of the same fields |
| DELETE | /clergy-titles/:id | AdminGuard (MEMBERS_WRITE) | 400 if any clergy member is still assigned to it |
MemberImportJob
Tracks a single bulk-import spreadsheet upload from preview through commit.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| originalFilename | string | Filename as uploaded |
| status | MemberImportJobStatus | READY_FOR_REVIEW | COMMITTED |
| totalRows | int | Total data rows parsed from the sheet |
| validRows | int | Rows with zero validation errors at preview time |
| createdCount | int | Members actually created on commit |
| failedCommitCount | int | Rows that still failed at commit time despite passing preview |
| createdBy | Admin | ManyToOne, onDelete: RESTRICT |
MemberImportRow
One row of a MemberImportJob’s source spreadsheet.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| job | MemberImportJob | ManyToOne, onDelete: CASCADE |
| rowNumber | int | 1-based spreadsheet row number (header is row 1) |
| data | jsonb | Parsed row fields — see MemberImportRowData interface |
| errors | jsonb (string[]) | Validation errors found at preview time; empty array = eligible to commit |
| status | MemberImportRowStatus | PENDING | CREATED | FAILED |
| createdMemberId | UUID | null | Set once the row’s member is created |
| commitError | string | null | Set only if the row passed preview validation but still failed at commit time |
Event
A church gathering on a specific date.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | string | |
| description | string | Optional |
| eventDate | Date (date only) | Derived, not user-entered: the earliest serviceSlots[].startTime (UTC date). Recomputed whenever slots are (re)created via EventService. Date-level only — use for date-range filtering, not “is this event over” checks. |
| endDate | Date (date only) | Derived: the latest serviceSlots[].endTime (UTC date). Recomputed alongside eventDate. Same date-only caveat as eventDate. |
| startTime | timestamptz | Derived: the precise instant of the earliest slot’s startTime (not truncated). Recomputed alongside eventDate. |
| endTime | timestamptz | Derived: the precise instant of the latest slot’s endTime (not truncated). Use this — not endDate — for “is this event past/live” checks, since endDate only has day-level granularity. |
| attendanceMarked | boolean | Set to true by the cron job after absence records are created. Guards against double-processing. |
| onlineAttendanceEnabled | boolean | Default false. When true, absent members receive an online-confirm email after the event ends. |
| onlineNotificationSentAt | timestamptz | null | Set when the online-confirm emails are dispatched. Used to calculate the confirmation window. |
| onlineConfirmClosesAt | timestamptz | null | When members can no longer confirm online attendance; fixed when the online-confirm emails go out. |
| thankYouSentAt | timestamptz | null | Set after thank-you emails are queued for the event; guards against resending on re-trigger. |
| recurringEventId | UUID | Groups events in a recurring series; for series created since EventSeries exists, this is event_series.id |
| seriesOccurrenceDate | date | null | Church-local date this occurrence stands for in its series; unique per series (UQ_events_series_occurrence) |
| audience | string | EVERYONE (default) | WORKERS | GROUP — who the event is for (see Event audience) |
| audienceGroupId | UUID | null | FK groups (SET NULL), GROUP only; a deleted group makes the event open to everyone |
| serviceSlots | ServiceSlot[] | OneToMany — at least one slot is required at creation |
| attendances | Attendance[] | OneToMany |
EventSeries
The repeat rule behind a recurring event (tenant table event_series).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK; occurrences carry it as events.recurring_event_id |
| name / description | string | Copied onto each new occurrence |
| onlineAttendanceEnabled | boolean | |
| recurrencePattern | string | daily | weekly | monthly |
| recurrenceInterval | int | Every N units |
| startDate | date | First occurrence (church-local) |
| endDate | date | null | null = ongoing |
| slotBlueprint | jsonb | SlotBlueprint[] (times of day + durations) |
| autoProgramme | boolean | Default true; prepare draft programmes from templates |
| generatedThrough | date | null | Last occurrence date created; generation never goes back behind it |
| isActive | boolean | Partial index IDX_event_series_active on generated_through WHERE is_active |
| createdBy | Member | null | SET NULL on delete |
EventTemplate
A saved service type (tenant table event_templates).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | string | Unique case-insensitively (LOWER(name) unique index) |
| description | string | null | |
| onlineAttendanceEnabled | boolean | |
| slotBlueprint | jsonb | SlotBlueprint[] |
| defaultRecurrence | jsonb | null | { recurrencePattern, recurrenceInterval, ongoing, weekday? } |
| autoProgramme | boolean | Default true |
Venue
A named, reusable physical location. Referenced by EventConfig.defaultVenue and optionally overridden per slot via
ServiceSlot.venueOverride; also optionally referenced by SmallGroup.venue (see Small Group Module).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | string | Unique |
| address | string | Optional |
| latitude | float | WGS84 latitude |
| longitude | float | WGS84 longitude |
Deleting a venue that is set as defaultVenue on any EventConfig is rejected by the DB FK constraint. Deleting a
venue that is a slot-level venueOverride sets that field to null (SET NULL).
ServiceSlot
The actual check-in target within an event. One event can have multiple slots.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| event | Event | ManyToOne |
| name | string | Default: “Service” |
| startTime | timestamptz | |
| endTime | timestamptz | |
| config | EventConfig | ManyToOne, nullable |
| venueOverride | Venue | null | ManyToOne, nullable — overrides config.defaultVenue for this slot |
| formatOverride | MeetingFormatEnum | null | Nullable — overrides config.defaultFormat for this slot (IN_PERSON | ONLINE) |
| *Override columns | int | Per-slot overrides that take priority over EventConfig |
Override columns: workerCheckinStartOverride, workerLateOverride, memberCheckinStartOverride,
checkinStopOverride, allowedDistanceOverride; plus checkinCloseModeOverride (string | null — SERVICE_END | AFTER_START, overrides config.checkinCloseMode for this slot)
Resolution (EventService.resolveSlotConfig): format = slot.formatOverride ?? config.defaultFormat;
venue = slot.venueOverride ?? config.defaultVenue. Throws 400 only when the resolved format is IN_PERSON and
venue is still null — an ONLINE-resolved slot never requires a venue. A slot overriding an ONLINE config back
to IN_PERSON must supply its own venueOverride; enforced at save time (EventService.buildSlotFromDto), not
just at first check-in.
EventConfig
A reusable timing template assigned to service slots. Venue is a first-class relation rather than raw lat/lon.
| Field | Type | Description |
|---|---|---|
| name | string | Unique |
| defaultVenue | Venue | null | ManyToOne, nullable, RESTRICT on delete — required when defaultFormat is IN_PERSON, forbidden when ONLINE (enforced in EventConfigService, not a DB constraint) |
| defaultFormat | MeetingFormatEnum | IN_PERSON | ONLINE. Default IN_PERSON — every pre-existing config keeps its behavior unchanged |
| onlineMeetingUrl | string | null | Optional join link shown to members/workers when the resolved format is ONLINE |
| workerCheckinStartOffsetSeconds | int | Seconds relative to startTime when workers can start checking in. Negative = before start |
| workerLateOffsetSeconds | int | Seconds after startTime after which workers are LATE |
| memberCheckinStartOffsetSeconds | int | When members can start checking in |
| checkinStopOffsetSeconds | int | When check-in closes for everyone (AFTER_START only; never after the service’s end) |
| checkinCloseMode | string | SERVICE_END (closes when each service ends) | AFTER_START (default for existing rows) |
| allowedDistanceInMeters | int | Max distance from the resolved venue for location validation (ignored for ONLINE) |
| autoStartSession | bool | Default false. See ProgrammeAutoStartScheduler (Service Programme section) below |
Constraint: workerLateOffset > workerCheckinStartOffset and checkinStopOffset > workerLateOffset
MeetingFormatEnum (src/utility/enum/meeting-format.enum.ts, shared with SmallGroup): IN_PERSON | ONLINE.
Deliberately two values, no HYBRID.
Check-in behavior for ONLINE slots: AttendanceService.checkin skips the “workers must provide location”
requirement when the resolved slot format is ONLINE, and never runs distance validation against a null venue.
Attendance
One record per member per event. Workers and members both receive one attendance record per event; workers are distinguished by a LATE status if they arrive after the threshold.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| member | Member | ManyToOne, CASCADE on delete |
| event | Event | ManyToOne, CASCADE on delete — the event being attended |
| serviceSlot | ServiceSlot | ManyToOne, nullable, SET NULL on delete — which slot they entered. Indexed. |
| status | AttendanceStatusEnum | PRESENT | LATE | ABSENT | ON_LEAVE | ATTENDED_ONLINE |
| checkinTime | timestamptz | Null for cron-created ABSENT/ON_LEAVE records |
| roleAtCheckin | MemberRoleEnum | Snapshot of role at check-in time |
| location | JSON | {latitude, longitude} or null; mandatory for workers at check-in |
Unique constraint: (member, event) — one record per person per event.
Streak rules:
- PRESENT, LATE, and ATTENDED_ONLINE all count as present and increment the streak.
- ON_LEAVE is neutral — it neither increments nor breaks the streak.
- ABSENT breaks the streak.
Department
| Field | Notes |
|---|---|
| id | UUID PK |
| name | Unique |
| description | |
| capabilities | DepartmentCapability[] (text[], default {}) — fixed, code-defined feature flags this department grants to its workers (both primary and secondary). Validated against the DepartmentCapability enum (src/department/enums/department-capability.enum.ts) — unlike the key column it replaced, a capability only exists if a real feature is gated on it, and a single department can hold more than one. |
| workerProfiles | OneToMany → WorkerProfile |
DepartmentLead
Joins a WorkerProfile to a Department as head or assistant lead.
PastorFeedback
Weekly structured feedback a department’s HOD or Assistant HOD (D_HOD) submits, which a pastor can read and respond to — from both the admin portal and the mobile app.
| Field | Notes |
|---|---|
| department | ManyToOne → Department (onDelete: CASCADE — historical feedback for a deleted department is meaningless to retain) |
| submittedBy | ManyToOne → WorkerProfile, nullable (onDelete: SET NULL — a later worker revocation shouldn’t be blocked by old feedback) |
| submittedByName | Snapshotted at submit time (mirrors AuditLog’s targetName pattern) so history survives regardless of the live FK |
| weekOf | date — the Monday of the week being reported on (canonical anchor, unambiguous) |
| attendanceNotes | text, required |
| highlights | text, required |
| challenges | text, required |
| prayerRequests | text, nullable |
| additionalNotes | text, nullable |
| submittedAt | auto timestamp |
| respondedByClergy | ManyToOne → Clergy, nullable (onDelete: SET NULL) |
| respondedByClergyName | Snapshotted at response time, same rationale as submittedByName |
| pastorResponse | text, nullable |
| pastorRespondedAt | timestamp, nullable |
Unique constraint: (department, weekOf) — one submission per department per week. Editing after submission is a PATCH on the same row; there’s no draft/submitted status or read-receipt lock.
Ownership check (submission/edit): the caller must be an HOD or Assistant HOD (DepartmentLead row) of the target department — checked via DepartmentLead.exists({ workerProfile, department }), mirroring the isHod check in auth.service.ts:getProfile(). Not gated by RolesGuard/@Roles(WORKER) alone, since being a worker isn’t sufficient — must specifically lead that department.
Ownership check (feedback response, added 2026-08 — now stricter than mere Clergy existence): the caller must have a Clergy record with canReviewFeedback: true (PastorFeedbackService.assertCanReviewFeedback()), not just any clergy designation regardless of title. This is deliberately decoupled from title — being promoted to a new title (or holding any title at all) does not by itself grant the ability to see and respond to every department’s reports; an admin grants that separately via PATCH /members/:id/clergy/review-access, defaulting true for existing clergy so nothing broke on rollout. Available via both the admin portal (an Admin account whose linked Member has such a Clergy record) and the mobile app.
PrayerRequest
A private prayer request submitted by any member/worker — visible only to the submitter, Prayer department workers, and clergy.
| Field | Notes |
|---|---|
| member | ManyToOne → Member, nullable (onDelete: SET NULL — a deactivated member’s request history survives) |
| submittedByName | Snapshotted at submit time, same rationale as PastorFeedback.submittedByName |
| content | text, required |
| status | OPEN | PRAYED_FOR | ANSWERED (character varying, default OPEN) |
Testimony
An opt-in-public testimony — either tied to one of the submitter’s own prayer requests, or general.
| Field | Notes |
|---|---|
| member | ManyToOne → Member, nullable (onDelete: SET NULL) |
| submittedByName | Snapshotted at submit time |
| prayerRequest | ManyToOne → PrayerRequest, nullable (onDelete: SET NULL) — null means a general testimony |
| content | text, required |
| isPublic | boolean, default false — the submitter’s own opt-in flag set at submission time; no separate publish/moderation step |
PregnancyPrayerCase
Tracks a pregnant woman receiving ongoing prayer support — created and managed by the Prayer team (or pastors), not self-submitted. Lives in the same src/prayer-request/ module and reuses PRAYER_READ/PRAYER_WRITE — no new permission.
| Field | Notes |
|---|---|
| member | ManyToOne → Member, nullable (onDelete: SET NULL) — she may not be an existing member |
| name | Snapshot, always present regardless of member |
| edd | date — estimated due date |
| details | text, nullable — general context/notes |
| status | ACTIVE | DELIVERED | DISCONTINUED (character varying, default ACTIVE) |
| lastPrayedAt | timestamptz, nullable — denormalized, updated whenever a new PregnancyPrayerVisit is logged |
| createdBy | ManyToOne → Member, nullable (onDelete: SET NULL) |
| createdByName | Snapshotted at creation time |
PregnancyPrayerVisit
A log entry recorded each time the Prayer team prays with/visits a pregnant woman — mirrors the FirstTimerVisit idiom in the Follow-Up module.
| Field | Notes |
|---|---|
| case | ManyToOne → PregnancyPrayerCase (onDelete: CASCADE) |
| loggedBy | ManyToOne → Member, nullable (onDelete: SET NULL) |
| loggedByName | Snapshotted at log time |
| note | text, nullable — follow-up note |
| visitedAt | timestamptz, default now |
RequestLeave
| Field | Notes |
|---|---|
| workerProfile | ManyToOne → WorkerProfile |
| dateFrom / dateTo | date (YYYY-MM-DD, no time component) |
| reason | string |
| status | PENDING | APPROVED | REJECTED |
| actionedBy | ManyToOne → Member (admin who approved/rejected) |
ChurchClass
| Field | Notes |
|---|---|
| classType | ManyToOne → ClassType (nullable: false, onDelete: RESTRICT) |
| startDate / endDate | date strings |
| nextSessionAt | timestamptz, nullable — the next session time used by session reminders. Once a class has ClassSession rows it is kept in step automatically (next upcoming session); without sessions it’s still set by hand via PATCH /classes/:id/session |
| meetingLink | varchar, nullable — join link shown alongside nextSessionAt; synced from the next session when that session has its own link |
| minAttendancePercent | int 1–100, nullable — completion rule (null = no attendance rule) |
| requireAllAssignments | boolean, default false — completion rule: every published assignment submitted |
| openForRequests | boolean, default false — members can ask to join from the app |
| capacity | int, nullable — max people IN_PROGRESS; requests/approvals are refused when full |
| materials | OneToMany → ClassMaterial, cascade: true — see below |
| facilitators | OneToMany → ClassFacilitator, cascade: true — see below |
Delete guard: Deleting a class is blocked if any enrolment record exists (any status — IN_PROGRESS, COMPLETED, or CANCELLED). This preserves historical enrolment data. A class with enrolment history cannot be deleted. Deleting an allowed (enrolment-free) class also cleans up its materials’ Cloudinary assets first (see ClassMaterial below) — the FK’s onDelete: CASCADE removes the class_materials rows automatically, but nothing app-side fires on a DB-level cascade, so this cleanup has to happen explicitly before the class row is removed. class_facilitators rows cascade-delete too, but need no app-side cleanup — there’s no external asset attached to a facilitator row.
ClassFacilitator
Replaces the old ChurchClass.facilitator (a single Member FK). A class can have several facilitators, and not every facilitator is a registered Member — an outside guest speaker is named via free text instead.
| Field | Notes |
|---|---|
| churchClass | ManyToOne → ChurchClass (nullable: false, onDelete: CASCADE) |
| member | ManyToOne → Member, nullable (nullable: true, onDelete: SET NULL) |
| guestName | varchar, nullable |
| order | int, default 0 — display order |
Exactly one of member/guestName is set per row — validated in ClassesService (not the DTO, since class-validator can’t cleanly express “exactly one of two fields”); an entry with both or neither throws BadRequestException.
A class must always have at least one facilitator. CreateChurchClassDto.facilitators is a required, non-empty array (@ArrayMinSize(1)). UpdateChurchClassDto.facilitators is optional — omit it to leave the existing facilitators untouched — but if provided, it must also be non-empty and replaces the full list (no incremental add/remove endpoints, unlike ClassMaterial; a facilitator has no upload step or cross-class reuse concern to preserve).
Request shape for both create and update: facilitators: [{ memberId?: string, guestName?: string }].
Next session (nextSessionAt/meetingLink): PATCH classes/:id/session (body: UpdateClassSessionDto — both fields optional, either can be set to null to clear it) lets a facilitator/admin record when the class next meets and how to join. Deliberately a single mutable pair of columns rather than a ClassSession entity — a class is expected to have one upcoming session in view at a time, updated in place as it progresses, not a pre-populated calendar. Feeds ClassSessionReminderScheduler (see Reminder Settings Module) and is surfaced on both the authenticated member class-detail view and the guest portal (GET classes/guest/:enrollmentId).
ClassMaterial
Replaces the old ChurchClass.documentUrl (a single free-text URL — the previous uploadMaterial() also never persisted Cloudinary’s publicId, so nothing could ever be deleted). One-to-many, so a class can carry multiple titled documents and links, each independently addable/removable.
| Field | Notes |
|---|---|
| churchClass | ManyToOne → ChurchClass (nullable: false, onDelete: CASCADE) |
| title | required — defaults to the uploaded file’s name (extension stripped) when omitted on upload |
| url | the Cloudinary secure URL (upload) or the pasted external link |
| publicId | nullable — Cloudinary asset id; null for a pasted link (nothing to delete from Cloudinary) |
| resourceType | nullable — Cloudinary resource type (image/video/raw); null for a pasted link |
| mimeType | nullable — set for uploads only |
| sizeBytes | bigint, nullable — set for uploads only |
| order | int, default 0 — display order, assigned incrementally as materials are added |
Three ways to add a material (all class-scoped, AdminGuard + CLASSES_WRITE):
POST classes/:id/materials/upload— multipart, fieldfile(+ optionaltitlefield), same file-type allowlist as before (PDF/Word/PowerPoint/image), size gated byPlatformSettingKey.MAX_CLASS_MATERIAL_UPLOAD_MBviaDynamicLimitedFileInterceptor. Uploads to theclass-materialsCloudinary folder and creates the row in one call.POST classes/:id/materials/link— JSON{ title, url }, no Cloudinary asset (publicId: null).POST classes/:id/materials/reuse— JSON echoing aGET classes/materials/libraryentry’s fields back; creates a new row pointing at the same Cloudinary asset (or the same pasted URL) without a new upload.
Reference-counted deletion (DELETE classes/:id/materials/:materialId): because “reuse” lets multiple ClassMaterial rows share one publicId, deleting a row only calls CloudinaryService.deleteByPublicId() if no other row still references that publicId — checked via a live exists() query at delete time (not a stored counter, so it can never drift out of sync). A pasted-link row (publicId: null) never touches Cloudinary at all. The same check runs for every material when a class itself is deleted.
Indexes: church_class_id (FK, backs the materials relation join and the pre-delete cleanup loop’s per-class fetch) and public_id (backs the reference-counted exists() check above, which runs on every material/class deletion).
Library (GET classes/materials/library, CLASSES_READ): dedups every material across every class by publicId (uploads) or url (pasted links with no publicId), returning { title, url, publicId, resourceType, mimeType, sizeBytes, usedByClassNames }[] — lets the admin UI’s “Reuse Previous” picker show what’s already in use and by which classes, instead of re-uploading the same syllabus for every cohort.
Visibility: GET classes/:id (admin) and the guest portal (GET classes/guest/:enrollmentId) both return materials sorted by order; the guest portal’s hand-built response only exposes {id, title, url, resourceType} per material, not the full row.
ClassType
Replaces the old hardcoded ChurchClassTypeEnum — class types are now admin-creatable and admin-editable, not a fixed set. ChurchClassTypeEnum still exists in code purely as a reference for the migration’s seed data; it’s not used at runtime anymore (removed from the generic /enums endpoint’s churchClassTypes key for the same reason — it no longer reflects reality once admins add their own types).
| Field | Notes |
|---|---|
| name | unique |
| description | nullable text |
| isActive | boolean, default true — deactivated types are hidden from class-create pickers but existing classes keep referencing them (RESTRICT prevents hard-deleting a type still in use) |
| nextClassType | ManyToOne → ClassType, nullable, self-referencing (onDelete: SET NULL) |
Promotion chain: nextClassType is a self-referencing pointer, not a level number — a class type either points to the next type in its progression or is null (standalone, no promotion). The chain is entirely admin-configured via the ClassType CRUD endpoints; nothing is pre-wired by the migration (the 5 seeded legacy types — Believers’ Class, Baptismal Class, Workers in Training, Bible College, School of Discipleship — all seed with nextClassType = null). Writes are validated server-side against self-reference and cycles (walks the proposed chain up to 20 hops looking for a loop back to the type being edited) since a DB FK can’t express “no cycles.”
Seeded ids re-keyed (migration 1799823600000-ReplaceSeededClassTypeIds): the genesis seed used ids like
11111111-0000-0000-0000-000000000001, which aren’t RFC 4122 UUIDs, so @IsUUID() and ParseUUIDPipe rejected them
(“classTypeId must be a UUID” when creating a class; editing/deleting a seeded type also failed). The migration gives
those rows gen_random_uuid() ids and repoints church_classes.class_type_id and class_types.next_class_type_id;
custom types are untouched. The class-type list cache key moved to class-types:all:v2 so stale cached ids aren’t served.
Guest
A non-member taking a Training Class — e.g. a visitor’s spouse attending marriage counselling alongside their member partner. Deliberately its own entity, not inline columns on ClassEnrollment: a guest is a repeat, evolving identity that may take several classes over time, so contact details are stored once here and referenced from each enrollment, rather than duplicated (and risking staleness) per enrollment row.
| Field | Notes |
|---|---|
| firstName | required |
| lastName | required |
| required, unique — the primary contact channel, prioritized over phone | |
| phone | nullable — optional, used only if the guest opts into SMS |
| churchName | nullable — their home church, if any |
| address | nullable |
| notes | text, nullable — open catch-all |
| convertedMember | ManyToOne → Member, nullable, onDelete: SET NULL — set once this guest converts to a full Member; kept as a permanent historical link even after ClassEnrollment.member is also updated directly (see conversion below) |
Find-or-create by email: GuestService.findOrCreateByEmail() backs both the “new guest” enrollment form (profile fields provided, no prior record) and the “existing guest” search-and-select path — a returning guest’s contact details are looked up once by email, not re-entered per class.
Conversion (POST classes/guests/:guestId/convert-to-member): admin-only, scoped to the guest record (not one enrollment) — reuses MemberService.createByAdmin(), the same temp-password + forced-change-password + welcome-email flow used for every other admin-created member. Builds a SignupDto from the guest’s firstName/lastName/email/phone, creates the member, sets guest.convertedMember, then bulk-updates every ClassEnrollment referencing that guest to point at the new member directly — so downstream code (reminders, Announcements audience resolution, submissions) never needs to know about the guest→member relationship, only “does this enrollment have a member.” The guest record and its profile data are kept as history, not cleared. Audit-logged as GUEST_CONVERTED_TO_MEMBER.
ClassEnrollment
| Field | Notes |
|---|---|
| member | ManyToOne → Member, nullable |
| guest | ManyToOne → Guest, nullable, indexed, onDelete: SET NULL |
| purpose | text, nullable — why this specific enrollee is taking this specific class; per-enrollment (not per-guest), since the same guest could take two classes for two different reasons |
| churchClass | ManyToOne → ChurchClass |
| status | IN_PROGRESS | COMPLETED | CANCELLED |
| enrolledAt | auto timestamp |
| completedAt / cancelledAt | set when status changes |
| certificateIssued | boolean, default false |
| certificateIssuedAt | timestamptz, nullable |
| certificateNumber | varchar, nullable |
Exactly one of member/guest is required — DB-level CHECK constraint member_id IS NOT NULL OR guest_id IS NOT NULL. Not exclusive (not XOR): a converted guest ends up with both set — guest is retained as history, member is set at conversion time (see Guest above).
Unique constraints: (member, churchClass) and (guest, churchClass) — a member and a guest can each only have one enrollment record per class.
Guest portal access: a guest never logs in — their own ClassEnrollment.id (already a random-looking UUID) is the access key, mirroring the Forms module’s public-submission pattern (@Public(), no token-generation system). On enrollment, class-guest-access emails a link to GET classes/guest/:enrollmentId (frontend: discuva-member’s app/classes/guest/[id]/page.tsx, no Shell/withAuth). That route + the paired POST classes/guest/:enrollmentId/assignments/:assignmentId/submit are the only surface a guest can reach — no other member-app feature, no self-serve conversion.
Level promotion: When an enrollment is COMPLETED and its class’s classType.nextClassType is set, GET classes/enrollments/:enrollmentId/promotion-candidate reports eligibility plus any currently-open (ACTIVE) classes of that next type. Promotion itself is a separate, explicit, admin-confirmed action — POST classes/enrollments/:enrollmentId/promote (body: targetClassId) — mirroring the promoteToWorker pattern (transaction + CLASS_LEVEL_PROMOTED audit log entry + a class-level-promotion templated email to the member). Nothing is auto-enrolled on completion; standalone class types (no nextClassType) simply have no promotion affordance.
Certificates: Once an enrollment is COMPLETED, PATCH classes/enrollments/:enrollmentId/certificate (body: optional certificateNumber) marks it as having received a certificate — sets certificateIssued = true, certificateIssuedAt = now(), and stores certificateNumber if given. This is a manual, admin-confirmed record only (no file upload); it logs CLASS_CERTIFICATE_ISSUED.
Announcement
| Field | Notes |
|---|---|
| audience | ALL | WORKERS_ONLY | MEMBERS_ONLY | DEPARTMENT | INDIVIDUAL | GROUP | CLASS |
| department | ManyToOne → Department (required when audience=DEPARTMENT) |
| targetMember | ManyToOne → Member, nullable (required when audience=INDIVIDUAL) |
| group | ManyToOne → Group, nullable (required when audience=GROUP) |
| churchClass | ManyToOne → ChurchClass, nullable, column class_id (required when audience=CLASS) |
| publishedAt | defaults to creation time |
| expiresAt | nullable; expired items excluded from feed |
| sendViaSms | boolean, default false; requires the caller’s admin role to hold SMS_SEND (see SMS Module) |
| smsBody | text, nullable; required when sendViaSms=true; deliberately separate from body since SMS is billed per segment |
AnnouncementReaction
A member’s emoji reaction to an announcement they received.
| Field | Notes |
|---|---|
| announcement | ManyToOne → Announcement, onDelete: CASCADE, indexed |
| member | ManyToOne → Member, onDelete: CASCADE |
| emoji | character varying, validated against a fixed set (ReactionEmojiEnum: 👍 ❤️ 🙏 🎉 👏) |
Unique constraint: (announcement, member) — one reaction per member per announcement. Reacting again with a different emoji updates the existing row rather than adding a second one (not Slack-style multi-emoji-per-user).
Group
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | string | Unique |
| description | text | null | Optional |
| createdBy | Member | null | ManyToOne, SET NULL on delete. Column is created_by_id (see migration note below). |
| members | GroupMember[] | OneToMany reverse side |
GroupMember
Join entity between Group and Member. @Unique(['group', 'member']) prevents duplicate membership rows.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| group | Group | ManyToOne, CASCADE on delete |
| member | Member | ManyToOne, CASCADE on delete |
| addedBy | Member | null | ManyToOne, SET NULL on delete. Column is added_by_id (see migration note below). |
Migration note: AddGroupsModule originally created these FK columns as created_by/added_by. Neither entity
has an explicit @JoinColumn, so SnakeNamingStrategy.joinColumnName (which names join columns as
<relation>_<referencedColumn>) expects created_by_id/added_by_id at runtime — the mismatch caused
column grp.created_by_id does not exist whenever the relation was selected (e.g. the announcements list with
audience=GROUP). Fixed by migration FixGroupsForeignKeyColumnNames, which renames both columns; per the
immutable-migrations rule the original AddGroupsModule file was left untouched.
BirthdayWish
Persists birthday wishes permanently, grouped by year. The birthday announcement expires but wishes remain readable indefinitely.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| message | text | DOMPurify-sanitized plain text, max 500 chars |
| recipient | Member | ManyToOne, CASCADE on delete |
| sender | Member | null | ManyToOne, SET NULL on delete |
| year | smallint | Calendar year the wish was sent |
Unique constraint: (recipient, sender, year) — one wish per sender per recipient per year.
AdminRole
A named role in the admin RBAC system. Carries a list of permissions.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | string | Unique (e.g. “SuperAdmin”, “ContentManager”) |
| description | string | Optional |
| permissions | AdminPermission[] | simple-array column; subset of AdminPermission enum |
| admins | Admin[] | OneToMany |
Admin
Links a church member to an admin role. This is the portal-access record — it is separate from the member’s church role (MEMBER/WORKER).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| member | Member | OneToOne, CASCADE on delete — the admin must be a church member |
| adminRole | AdminRole | ManyToOne, RESTRICT on delete — deleting a role with active admins is blocked |
| isActive | boolean | Soft-disable without revoking the role |
| favouritePages | string[] (jsonb) | Admin-portal routes the admin pinned to the dashboard’s Quick Access, in their order; max 12. Default []. Tenant migration AddAdminFavouritePages |
Relationship: A church worker can also be an admin. Having role=WORKER on the Member entity and an Admin record
are independent. Mobile app routes check role=WORKER; admin portal routes check the admins table.
Email notifications:
POST /admin/users(grant) — sends awelcome-adminemail to the member containing their email address and the admin portal login URL. Password is not re-generated; the message instructs the user to log in with their existing account password.POST /admin/users/:id/revoke— sends anaccount-deactivatedemail to the affected member.
AuditLog
Immutable record of every admin write action.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| action | AuditAction | String enum — see Audit Actions below |
| actor | Member | null | ManyToOne FK to members.id, SET NULL on member delete — the admin who performed the action |
| targetId | UUID | null | The ID of the affected resource (member, event, department, etc.) |
| targetName | string | null | Human-readable label for the target (a group’s name, an event’s title, a member’s full name — whatever the target actually is) |
| targetEmail | string | null | Email snapshot for identity tracing when targetId alone is insufficient |
| metadata | jsonb | null | Action-specific details (role changed, count of records affected, etc.) |
| createdAt | timestamptz | Auto-set on insert |
Indexes: action, actor (FK column actorId), targetId, createdAt.
Write path: AuditLogService.log() enqueues a job on the audit-log Bull queue (fire-and-forget, 3 attempts, exponential backoff). AuditLogProcessor handles the actual DB write asynchronously. Failed writes are retained in Redis (removeOnFail: false) and visible in Bull Board.
Actor traceability: The actor relation is a real FK to the members table. When building an audit log API, load
the relation (relations: ['actor']) to access actor name and email. If the member account is deleted, actor is set
to null but the log record and all other fields are preserved.
targetName convention: the admin audit-log list view only ever renders targetName ?? targetEmail ?? "—" for
its Target column — it never falls back to displaying the raw targetId, since a bare UUID isn’t meaningful to an
admin reading the trail. Every auditLogService.log(...) call site that sets a targetId should also set
targetName using whatever human-readable field is already in scope on the entity being acted on (a .name/.title
field from an entity already loaded via findOne/getOrThrow just above the call, or a ${firstname} ${lastname} for a person) — this is nearly always free, not a new query. A handful of genuinely nameless entities
(a journal entry, a petty cash request with no notes) fall back to the closest available proxy (a description
field, a formatted date range, free-text notes) rather than a fabricated label; where nothing reasonable exists,
targetName is left unset and the column honestly shows “—”.
EmailLog
Append-only delivery record written by the Bull email processor on every terminal outcome (success or permanent failure). Used for debugging delivery issues and compliance — answers “was this OTP email actually sent?”.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| recipient | string | To address(es), comma-joined if multiple |
| subject | string | Email subject line |
| status | varchar | sent | failed |
| jobId | string | Bull queue job ID — correlate with Redis for in-flight inspection |
| errorMessage | text | SMTP/API error on permanent failure; null on success |
| attemptsMade | int | Number of send attempts before terminal outcome (max 5) |
| provider | varchar | gmail | smtp | resend | sendgrid | mailgun — which email provider delivered (or attempted) the message |
| source | varchar | null | tenant (sent via the church’s own BYOK-configured provider) | platform_default (no tenant provider configured, Discuva’s EMAIL_PROVIDER default was used instead) | null for rows written before this column existed |
| createdAt | timestamptz | When the terminal outcome was recorded |
Written by: @OnQueueCompleted (status = sent) and @OnQueueFailed (status = failed, only on the final
attempt after all retries are exhausted). Transient failures that Bull subsequently retries do not produce a log
row — only the final outcome is recorded.
Provider/source resolution: EmailProcessor.handleSend resolves the actual provider and source (tenant vs
platform_default) before attempting the send, and persists both onto the job’s own data via job.update(). This
is what lets onFailed — which has no return value to read, since a thrown sendMail() call means handleSend
never reaches its return statement — log the provider/source that was actually being attempted rather than
guessing. (Previously onFailed hardcoded the platform default’s name unconditionally, mislabeling any failed send
that was actually attempted through a tenant’s own BYOK provider — fixed by this job.update() persistence.)
Indexes: recipient, status, createdAt.
Audit Actions:
ADMIN_CREATED · MEMBER_SIGNED_UP · MEMBER_LOGIN · MEMBER_LOGOUT · ADMIN_LOGIN · ADMIN_LOGOUT · PASSWORD_CHANGED ·
PASSWORD_RESET_REQUESTED · PASSWORD_RESET_COMPLETED · ADMIN_PASSWORD_RESET · WORKER_PROMOTED ·
WORKER_REVOKED · MEMBER_ACTIVATED · MEMBER_DEACTIVATED · MEMBER_UPDATED · MEMBER_CREATED_BY_ADMIN · MEMBER_PHOTO_UPDATED ·
MEMBER_PHOTO_REMOVED · ATTENDANCE_ADMIN_MARKED ·
PRAYER_REQUEST_SUBMITTED · PRAYER_REQUEST_STATUS_UPDATED · TESTIMONY_SUBMITTED · DEVICE_PURGED ·
DEVICE_RESET_REQUESTED · DEVICE_RESET_COMPLETED ·
ANNOUNCEMENT_CREATED · ANNOUNCEMENT_UPDATED · ANNOUNCEMENT_DELETED · EVENT_CREATED · EVENT_UPDATED ·
EVENT_DELETED · EVENT_SERIES_UPDATED · EVENT_SERIES_STOPPED · EVENT_TEMPLATE_SAVED · EVENT_TEMPLATE_DELETED · NOTE_CREATED · NOTE_UPDATED · NOTE_DELETED · LEAVE_APPROVED · LEAVE_REJECTED ·
DEPARTMENT_CREATED · DEPARTMENT_UPDATED · DEPARTMENT_DELETED · DEPARTMENT_LEAD_ASSIGNED ·
DEPARTMENT_LEAD_REMOVED · WORKER_PROFILE_UPDATED · ADMIN_ROLE_CREATED · ADMIN_ROLE_UPDATED ·
ADMIN_ROLE_DELETED · ADMIN_USER_CREATED · ADMIN_USER_UPDATED · ADMIN_USER_DEACTIVATED
TITHE_BATCH_QUEUED · TITHE_UNMATCHED_RESOLVED · TITHE_UNMATCHED_DISMISSED · TITHE_DISPUTE_APPROVED · TITHE_DISPUTE_REJECTED · TITHE_ACCOUNT_CREATED · TITHE_ACCOUNT_UPDATED ·
FINANCE_CATEGORY_CREATED · FINANCE_CATEGORY_UPDATED · FINANCE_CATEGORY_DELETED · FINANCE_REQUEST_CREATED · FINANCE_REQUEST_APPROVED · FINANCE_REQUEST_REJECTED · FINANCE_PROOF_ATTACHED ·
TITHE_PROOF_SUBMITTED · TITHE_PROOF_CONFIRMED · TITHE_PROOF_DECLINED · TITHE_PROOF_EXPIRED_PURGED ·
CHURCH_SETTING_UPDATED · INCIDENT_REPORT_CREATED · INCIDENT_REPORT_STATUS_UPDATED ·
ASSET_CREATED · ASSET_UPDATED · ASSET_MAINTENANCE_SCHEDULED · ASSET_MAINTENANCE_LOGGED · ASSET_INVENTORY_UPDATED ·
GUEST_CONVERTED_TO_MEMBER
EventReminder
Optional reminder schedule attached to a service slot. Multiple reminders can be configured per slot (one per interval preset).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| serviceSlot | ServiceSlot | ManyToOne, CASCADE on delete |
| audience | AnnouncementAudienceEnum | ALL | WORKERS_ONLY | DEPARTMENT |
| department | Department | null | Required when audience=DEPARTMENT |
| intervalPreset | ReminderIntervalPresetEnum | 15m | 30m | 1h | 3h | 24h | 48h |
| enabled | boolean | Admin can disable without deleting |
| lastSentAt | timestamptz | null | Set when the reminder fires; prevents double-sending |
| fireAt | timestamptz | null | Pre-computed: slot.startTime − preset_minutes. Set on create and on interval preset update; used by the dispatch cron to filter in SQL (no in-memory filtering) |
Unique constraint: (serviceSlot, intervalPreset) — one reminder per preset per slot.
SundaySchoolClass
A permanent Sunday School class. Members are assigned indefinitely (no graduation).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | string | |
| description | string | Optional |
| teacher | Member | null | ManyToOne, nullable — the appointed class teacher |
| assistants | Member[] | ManyToMany via sunday_school_class_assistants (class id, member id; both cascade). Up to 10. Same rights as the teacher for that class |
| ageGroup | string | null | Free text, e.g. “Ages 6–9” (≤60 chars) |
| meetingDay | MeetingDayEnum | null |
SUNDAY…SATURDAY (varchar) |
| meetingTime | string | null | HH:mm, church local time |
| location | string | null | Room or place (≤120 chars) |
Delete guard (class): Blocked if any members are assigned or any sessions have been recorded. Remove all members and sessions before deleting.
Delete guard (session): Blocked if any attendance records exist for the session. Sessions with attendance cannot be deleted — this prevents silent cascade-deletion of historical attendance data.
SundaySchoolMember
Links a church member to a Sunday School class.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| member | Member | ManyToOne |
| sundaySchoolClass | SundaySchoolClass | ManyToOne |
| assignedAt | timestamptz | auto timestamp |
Unique constraint: (member, sundaySchoolClass)
SundaySchoolSession
One session (meeting) of a Sunday School class.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| sundaySchoolClass | SundaySchoolClass | ManyToOne |
| sessionDate | string (YYYY-MM-DD) | Date of the session |
| selfMarkClosesAt | timestamptz | null | Non-null and in the future means the self-mark window is open |
| notes | string | Optional session notes |
Unique constraint: (sundaySchoolClass, sessionDate)
SundaySchoolAttendance
One attendance record per member per session.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| session | SundaySchoolSession | ManyToOne |
| member | Member | ManyToOne |
| status | SundaySchoolAttendanceStatus | PRESENT | ABSENT | EXCUSED |
| markedByTeacher | boolean | True if a teacher/staff marked the record; false if self-marked |
| markedAt | timestamptz |
Unique constraint: (session, member)
SundaySchoolQuestion
A private question a student asked in one of their Sunday School classes, and its answer once one exists. Visible only to the asking member and Sunday School staff/the class’s teacher — never shared with the rest of the class.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| sundaySchoolClass | SundaySchoolClass | ManyToOne |
| askedBy | Member | ManyToOne — the asking student |
| questionText | text | Max 1000 chars (DTO-enforced) |
| answerText | text | null | Max 3000 chars (DTO-enforced); null until answered |
| answeredBy | Member | null | ManyToOne, nullable — the teacher/staff who answered |
| answeredAt | timestamptz | null | null until answered |
Indexes: sundaySchoolClass and askedBy are indexed — every query pattern this module has (per-class list,
per-student list, and the assignment check in askQuestion) filters on one of those two. The cross-class
getAllQuestions/adminGetAllQuestions endpoint is a deliberate exception — an unfiltered ORDER BY created_at DESC
across the whole table — with no index on createdAt: this table is scoped to one tenant’s Sunday School program, so
even years of activity stays small enough (realistically hundreds to low thousands of rows) that an in-memory sort
costs nothing meaningful; add one if that assumption ever stops holding.
ChildAgeGroup
Defines an age bracket for automatic child classification.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | string | e.g. “Nursery”, “Toddlers” |
| minAgeMonths | int | Inclusive lower bound in months |
| maxAgeMonths | int | Inclusive upper bound in months |
| displayOrder | int | UI sort order — lower numbers appear first. Use sequential integers (1, 2, 3…) to control the display order across age brackets. |
Delete guard: Deleting an age group is blocked if any child profiles are directly assigned to it or to any of its
class groups. This prevents silent orphaning — the admin must reassign or remove the affected children first.
Internally, ChildClassGroup rows CASCADE on age-group delete; ChildProfile.ageGroup and ChildProfile.classGroup
are SET NULL on delete.
ChildClassGroup
A physical class room or group within an age group.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | string | e.g. “Nursery Room A” |
| ageGroup | ChildAgeGroup | ManyToOne, CASCADE on delete |
| capacity | int | null | Optional room capacity |
| teacherNote | text | null | Optional notes for the teacher |
Delete guard: Deleting a class group is blocked if any child profiles are currently assigned to it. Reassign children before deleting.
ChildProfile
The central record for a registered child.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| firstname | string | |
| lastname | string | |
| dateOfBirth | string (YYYY-MM-DD) | Used for automatic age-group assignment |
| ageGroup | ChildAgeGroup | ManyToOne — auto-assigned from DOB |
| classGroup | ChildClassGroup | ManyToOne — auto-assigned from age group |
| photoUrl | string | null | Optional |
| specialNotes | string | null | Allergies, medical info, etc. |
| registeredBy | Member | null | ManyToOne, nullable |
| guardians | ChildGuardian[] | OneToMany |
ChildGuardian
A guardian or authorised pickup person for a child.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| child | ChildProfile | ManyToOne |
| fullName | string | |
| relationship | GuardianRelationshipEnum | MOTHER | FATHER | GRANDPARENT | SIBLING | UNCLE | AUNT | FAMILY_FRIEND | OTHER |
| phoneNumber | string | |
| string | null | Direct email; resolved at runtime as guardian.email ?? guardian.member.email |
|
| member | Member | null | ManyToOne, nullable — links guardian to a church member account |
| photoUrl | string | null | Optional |
| isAuthorizedPickup | boolean | Whether this guardian is allowed to pick up the child |
ChildCheckIn
One check-in/check-out record per child per session.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| child | ChildProfile | ManyToOne |
| serviceSlot | ServiceSlot | null | ManyToOne, nullable |
| pickupCode | string (6 chars) | Unique per check-in; sent to guardians via email |
| status | ChildCheckInStatusEnum | CHECKED_IN | CHECKED_OUT | FLAGGED |
| checkinTime | timestamptz | |
| checkoutTime | timestamptz | null | Set on checkout |
| droppedOffBy | ChildGuardian | null | ManyToOne, nullable |
| droppedOffByName | string | Name captured at drop-off |
| pickedUpBy | ChildGuardian | null | ManyToOne, nullable — set on checkout |
| pickedUpByName | string | null | Name captured at pickup |
| checkedInBy | Member | null | ManyToOne, nullable — staff member who performed the check-in |
| flagReason | string | null | Reason if status = FLAGGED |
TitheAccount
Finance-team-managed list of bank accounts members can pay tithes into. Each account carries its own currency so the church can accept payments in multiple currencies (e.g. NGN, USD).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| bankName | string | |
| accountNumber | string | Indexed |
| accountName | string | |
| currency | string | ISO 4217 code (3 chars). Indexed. |
| description | string | null | Optional note shown to members |
| isActive | boolean | Default true. Inactive accounts are hidden from members. Indexed. |
Indexes: idx_tithe_accounts_account_number, idx_tithe_accounts_currency, idx_tithe_accounts_is_active.
TitheUploadBatch
A batch record created when the finance team uploads an Excel file of tithe payments. Each batch is tied to a specific TitheAccount, so all records in the batch are credited to that account.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| uploadedBy | Admin | ManyToOne |
| titheAccount | TitheAccount | ManyToOne (non-nullable, RESTRICT) |
| fileName | string | |
| status | TitheBatchStatus | PENDING | PROCESSING | COMPLETED | FAILED |
| totalRows | int | Total rows in the spreadsheet |
| matchedRows | int | Rows matched to a member |
| unmatchedRows | int | Rows with no member match |
| disputedRows | int | Rows flagged as possible duplicates |
| rows | jsonb | null | Parsed row data stored for safe requeue |
| errorMessage | string | null | Error detail on FAILED batches |
| processedAt | timestamptz | null | Set when processing completes |
TitheRecord
A confirmed tithe payment matched to a member.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| member | Member | ManyToOne. Indexed. |
| batch | TitheUploadBatch | ManyToOne. Indexed. |
| amount | decimal (12,2) | |
| paymentDate | date | |
| reference | string | null | Optional bank reference |
| bankName | string | null | Sender’s bank from the CSV column — not the destination account |
| source | MANUAL_PROOF | PAYMENT_GATEWAY |
MANUAL_PROOF for CSV batch/proof-of-payment uploads, PAYMENT_GATEWAY for online checkout (see Giving Checkout Module) |
| externalReference | string | null | Only set for PAYMENT_GATEWAY rows — the GivingCheckoutSession id, which is the same reference string sent to the vendor at checkout (GivingCheckoutService.initiateCheckout’s giving_{uuid} reference) — so it’s what shows up in the church’s own Paystack/Flutterwave/etc. dashboard, genuinely reconcilable. GET /admin/tithes/records’s search param matches against it (and reference) in addition to member name/email; the admin UI’s Records tab Reference field displays reference ?? externalReference (the two are never both set on one row) since reference alone stays null for every gateway-sourced record. |
| paymentChannel | string | null | Only set for PAYMENT_GATEWAY rows — the specific vendor (paystack/flutterwave/kora/stripe) the payment cleared through. Admin UI (/finances/tithes, Records tab) surfaces this alongside the Source badge, and it’s included as its own column in the Excel export — needed for settlement/reconciliation, since two different gateways landing in two different merchant accounts both otherwise show only a generic “Gateway” source. |
| givingOption | GivingOption | null | ManyToOne, nullable, SET NULL. Only ever set for PAYMENT_GATEWAY rows where the member designated a purpose at checkout (see GivingOption below) — null means “General Giving,” not a data gap. |
Duplicate detection: (memberId, paymentDate, amount) — if all three match an existing record, the row is flagged as a dispute instead. The destination bank account is inherited from the batch’s titheAccount.
Online giving purpose categorization — GivingOption vs. Pledge (never both): at checkout, a member may optionally designate the payment either toward a GivingOption (Tithe/Offering/General Giving/Building Fund/etc. — admin-curated, see GivingOption below) or toward one of their own active Pledges — never both (InitiateGivingCheckoutDto rejects a request carrying both givingOptionId and pledgeId). A GivingOption designation creates this TitheRecord with givingOption set. A Pledge designation does not create a TitheRecord at all — it creates a PledgeContribution (status CONFIRMED directly, no admin review, since the webhook already verified the money cleared) via PledgeService.recordConfirmedContribution, keeping pledge fulfillment and general giving as genuinely separate ledgers. Designating toward a pledge requires the member to already have an ACTIVE Pledge for that campaign — checkout never auto-creates one on the fly.
Indexes: member_id, batch_id (single-column). Composite IDX_tithe_records_member_payment on (member_id, payment_date) for member giving history range queries.
TitheUnmatchedRecord
Rows from a batch where no member matched the email address.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| batch | TitheUploadBatch | ManyToOne |
| rawEmail | string | Email from the spreadsheet |
| amount | decimal (12,2) | |
| paymentDate | date | |
| reference | string | null | |
| bankName | string | null | |
| status | TitheUnmatchedStatus | PENDING | MATCHED | DISMISSED |
| matchedMember | Member | null | Set when manually resolved |
| resolvedBy | Admin | null | Set when manually resolved |
| resolvedAt | timestamptz | null |
TitheDisputeRecord
Rows that matched a member but would duplicate an existing TitheRecord.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| batch | TitheUploadBatch | ManyToOne |
| existingRecord | TitheRecord | ManyToOne — the conflicting record |
| member | Member | ManyToOne |
| amount | decimal (12,2) | |
| paymentDate | date | |
| reference | string | null | |
| bankName | string | null | |
| status | TitheDisputeStatus | PENDING | APPROVED | REJECTED |
| reviewedBy | Admin | null | |
| reviewedAt | timestamptz | null |
TithePaymentProof
A member-submitted proof of tithe payment awaiting finance-team review. Files are stored in Cloudinary and automatically purged after a configurable number of days (default 90, controlled by TITHE_PROOF_EXPIRY_DAYS).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| member | Member | ManyToOne. Indexed. |
| titheAccount | TitheAccount | ManyToOne (non-nullable, RESTRICT). Indexed. |
| amount | decimal (12,2) | |
| paymentDate | date | Indexed. |
| reference | string | null | |
| givingOption | GivingOption | null | ManyToOne (nullable, SET NULL). Indexed. What the member designated this payment for; null means General Giving. Carried onto the TitheRecord created on confirm. |
| proofUrl | string | Cloudinary secure URL |
| publicId | string | Cloudinary public ID (used for deletion) |
| resourceType | string | Cloudinary resource type returned at upload |
| status | TitheProofStatus | PENDING | CONFIRMED | DECLINED |
| reviewedBy | Admin | null | |
| reviewedAt | timestamptz | null | |
| financeNote | string | null | Reason supplied when declining |
| expiresAt | timestamptz | Set to TITHE_PROOF_EXPIRY_DAYS days from submission (default 90); file purged on expiry |
FinanceCategory
Admin-managed list of expense categories used on finance requests.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | string | Unique |
| description | string | null | |
| isActive | boolean | Default true. Disabling hides the category from the finance-worker picker (GET /finance/categories) without deleting it — categories already referenced by a FinanceRequest can’t be hard-deleted (FK RESTRICT), so disabling is the retirement path for those. |
FinanceRequest
An expense request raised by a department head (HOD).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| requestedBy | Member | ManyToOne — the HOD who submitted the request |
| department | Department | ManyToOne |
| category | FinanceCategory | ManyToOne |
| reason | text | Justification for the expense |
| amount | decimal (12,2) | |
| recipientBankName | string | |
| recipientAccountNumber | string | |
| recipientAccountName | string | |
| attachmentUrl | string | null | Cloudinary URL for optional budget/invoice upload |
| attachmentPublicId | string | null | Cloudinary public ID for attachment (deletion) |
| attachmentResourceType | string | null | Cloudinary resource type returned at upload |
| status | FinanceRequestStatus | PENDING | APPROVED | REJECTED |
| reviewedBy | Admin | null | Set on approve/reject |
| reviewedAt | timestamptz | null | |
| rejectionReason | text | null | Populated on rejection |
| proofUrl | string | null | Cloudinary URL for payment proof, set post-approval |
| proofPublicId | string | null | Cloudinary public ID for proof (deletion) |
| proofResourceType | string | null | Cloudinary resource type for proof |
| journalEntry | JournalEntry | null | ManyToOne, SET NULL — set when a finance-team admin opts to post this request’s payment to the ledger at proof-attachment time; see Finance Request Module below |
FirstTimer
A visitor recorded by a follow-up team worker or admin during or after a service.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| firstname | string | |
| lastname | string | |
| phone | string | |
| string | null | Optional | |
| source | FirstTimerSourceEnum | WALK_IN | ONLINE | REFERRAL |
| wantsToJoinChurch | boolean | Default false. Self-onboard form asks “Just visiting” / “I’d like to stay” on the main screen (unanswered stays false); admins and Follow-Up workers can edit it later |
| enjoyedAboutChurch | text | null | What the visitor enjoyed |
| wantsToJoinWorkforce | boolean | Default false |
| notes | text | null | Additional follow-up notes |
| visitedEvent | Event | null | ManyToOne, SET NULL on delete |
| createdByMember | Member | null | ManyToOne, SET NULL on delete — the follow-up worker who created the record |
| createdByAdmin | Admin | null | ManyToOne, SET NULL on delete — the admin who created the record |
| convertedMember | Member | null | ManyToOne, SET NULL on delete — linked when the first-timer becomes a member |
| convertedAt | timestamptz | null | Timestamp when admin marked this first-timer as converted |
| inviteSentAt | timestamptz | null | Timestamp when membership invitation email was last sent; guards against duplicates |
| followUpTask | FollowUpTask | OneToOne — auto-created on registration |
| visits | FirstTimerVisit[] | OneToMany — return visit records |
FollowUpTask
A task assigned to a follow-up team worker to engage a first-timer or online non-responder.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| type | FollowUpTaskTypeEnum | FIRST_TIMER | ONLINE_NO_RESPONSE | MANUAL |
| status | FollowUpTaskStatusEnum | PENDING | IN_PROGRESS | COMPLETED | UNREACHABLE |
| firstTimer | FirstTimer | null | OneToOne, CASCADE on delete — set when type=FIRST_TIMER |
| member | Member | null | ManyToOne, SET NULL on delete — set when type=ONLINE_NO_RESPONSE. Indexed. |
| event | Event | null | ManyToOne, SET NULL on delete — event context. Indexed. |
| assignedTo | WorkerProfile | ManyToOne, RESTRICT on delete — must be a FOLLOW_UP department worker |
| outcome | FollowUpOutcomeEnum | null | JOINED | DECLINED | NO_ANSWER | PRAYED_WITH |
| outcomeNotes | text | null | |
| dueDate | date | null | Optional target date |
| notes | FollowUpNote[] | OneToMany |
| lastActivityAt | timestamptz | Updated whenever a note is added or status changes; used to detect inactive tasks |
Round-robin assignment: The worker in the FOLLOW_UP department with the fewest open tasks (PENDING or IN_PROGRESS) is automatically selected. If no eligible worker exists, the API returns 400.
FollowUpNote
A note added by the assigned worker during follow-up interactions.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| task | FollowUpTask | ManyToOne, CASCADE on delete. Indexed. |
| addedBy | WorkerProfile | null | ManyToOne, SET NULL on delete |
| content | text | |
| contactMethod | ContactMethodEnum | null | PHONE_CALL | WHATSAPP | IN_PERSON | SMS | EMAIL — optional |
FirstTimerVisit
Records each return visit a first-timer makes before or after converting.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| firstTimer | FirstTimer | ManyToOne, CASCADE on delete. Indexed. |
| event | Event | null | ManyToOne, SET NULL on delete — event attended. Indexed. |
| visitedAt | date | YYYY-MM-DD — date of the visit |
| notes | text | null | Optional observation from the admin |
Convert
An evangelism outreach contact — not assumed to be an existing Member. See the Evangelism Module section below.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | varchar | Required — the only mandatory field on upload |
| phone | varchar | null | |
| notes | text | null | |
| status | varchar | UNSAVED | SAVED | UNDERGOING_DISCIPLESHIP, default UNSAVED. Indexed. |
| onboardedBy | Member | null | ManyToOne, SET NULL on delete. Indexed. |
| onboardedByName | varchar | Snapshotted at upload time |
| assignedTo | WorkerProfile | null | ManyToOne, SET NULL on delete. Indexed. Who is currently following up. |
| member | Member | null | ManyToOne, SET NULL on delete — set once the convert joins as a member |
| linkedAt | timestamptz | null | |
| lastContactedAt | timestamptz | null | Denormalized, updated on every new ConvertFollowUpLog |
ConvertFollowUpLog
One row per contact attempt with a convert — mirrors FirstTimerVisit.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| convert | Convert | ManyToOne, CASCADE on delete. Indexed. |
| loggedBy | Member | null | ManyToOne, SET NULL on delete |
| loggedByName | varchar | Snapshotted at log time |
| note | text | null | |
| contactedAt | timestamptz | Default now |
Sermon
Link-based sermon archive entry — no file uploads. See Sermon Module for the “Announce Live” trigger.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| title | varchar | |
| speakerName | varchar | Plain string, not a Member FK — guest speakers may not be in the system |
| date | timestamptz | Indexed; list is ordered newest-first |
| description | text | null | |
| youtubeUrl | varchar | null | At least one of youtubeUrl/mixlrUrl required |
| mixlrUrl | varchar | null | At least one of youtubeUrl/mixlrUrl required |
| series | varchar | null | Indexed; plain string tag, filterable, not its own entity |
| createdBy | Admin | null | ManyToOne, SET NULL on delete |
Note
A member’s private note (notes, tenant schema). See Notes Module.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| memberId | UUID | FK members, CASCADE; index (member_id, updated_at) |
| kind | varchar | sermon | personal | study (default personal) |
| title | varchar(200) | Default '' |
| content | jsonb | Editor document ({ type: 'doc', content: [...] }), max 200 KB |
| plainText | text | Derived on save; search and excerpts |
| scriptureRefs | jsonb (string[]) | Derived canonical refs, e.g. ROM.8.28 |
| commitment | varchar(200) | null | Derived from the “One thing I’ll do this week” prompt |
| wordCount | int | Derived; words outside headings (0 = untouched template) |
| sermonId | UUID | null | FK sermons, SET NULL; indexed |
| eventId | UUID | null | FK events, SET NULL; indexed |
| serviceSlotId | UUID | null | FK service_slots, SET NULL; unique with memberId when set |
| pinned | boolean | Default false |
ScriptureLinkTap
Daily totals of taps on copyrighted Bible versions that open on bible.com (scripture_link_taps, tenant schema).
PK (day, version); count int.
members.note_nudges (boolean, default true) — the member’s own switch for Notes reminders.
ServiceProgramme
One programme per service slot (unique constraint on service_slot_id). Status flows: DRAFT → LIVE → COMPLETED.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| serviceSlot | ServiceSlot | OneToOne, CASCADE on delete |
| status | varchar | DRAFT | LIVE | COMPLETED |
| saveAsTemplate | boolean | If true, upserts template on completion |
| createdByAdmin | Admin | null | ManyToOne, SET NULL on delete |
GET /service-programme and GET /service-programme/:id responses also include derived (non-persisted) fields for the admin frontend: serviceSlotId, serviceSlotName ("{eventName} — {slotName}"), and slotCount.
ServiceProgrammeSlot
Ordered items within a programme. Frozen when session starts; runtime changes go to ServiceSessionSlot.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| programme | ServiceProgramme | ManyToOne, CASCADE on delete |
| position | int | Zero-based order index |
| type | varchar | SPEAKER | BREAK |
| topic | varchar | null | |
| member | Member | null | Assigned speaker; SET NULL on delete |
| guestName | varchar | null | Free-text name for non-members |
| backupMember | Member | null | Backup speaker; SET NULL on delete |
| backupGuestName | varchar | null | |
| allocatedMinutes | int | Planned slot duration |
| reminderSentAt | timestamptz | null | Set once the day-before reminder email has been sent; prevents duplicate sends |
ServiceSession
One session per programme (unique constraint on programme_id). Created when a session starts.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| programme | ServiceProgramme | OneToOne, CASCADE on delete |
| sessionCode | varchar | Unique, e.g. SVC-ABC123 |
| status | varchar | LIVE | COMPLETED |
| startedAt | timestamptz | |
| endedAt | timestamptz | null |
Redis anchor (session:{sessionCode}:anchor, TTL 48 h after completion):
{ "currentSlotPosition": 0, "slotStartedAt": 1718000000000, "slotBaseSeconds": 0,
"status": "LIVE", "isPaused": false, "pausedAt": null }
Clients compute elapsed = slotBaseSeconds + (Date.now() - slotStartedAt) / 1000. No server-side ticker.
ServiceSessionSlot
Snapshot of each programme slot at session start. Runtime overrides stored here; planned data stays on ServiceProgrammeSlot.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| session | ServiceSession | ManyToOne, CASCADE |
| programmeSlot | ServiceProgrammeSlot | ManyToOne, CASCADE |
| position | int | |
| status | varchar | PENDING | IN_PROGRESS | COMPLETED | SKIPPED |
| adjustedAllocatedMinutes | int | null | Runtime time override |
| overriddenTopic | varchar | null | |
| overriddenSpeakerName | varchar | null | Display-only; analytics still uses member FK |
| overriddenMember | Member | null | If actual speaker changed mid-session |
| actualSeconds | int | null | Measured speaking time |
| startedAt | timestamptz | null | |
| completedAt | timestamptz | null |
ServicePauseEntry
One row per pause event during a session.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| session | ServiceSession | ManyToOne, CASCADE |
| slotPosition | int | Slot active at pause time |
| reason | varchar | ServicePauseReasonEnum |
| pausedAt | timestamptz | |
| resumedAt | timestamptz | null | Null until resumed |
ServiceActionEntry
Audit log of all control actions taken during a session.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| session | ServiceSession | ManyToOne, CASCADE |
| actorRole | varchar | ADMIN | WORKER | PUBLIC_LINK |
| action | varchar | e.g. ADVANCE_SLOT, PAUSE, TIME_ADJUSTED, SLOTS_REORDERED |
| detail | varchar | null | |
| performedByMember | Member | null | SET NULL on delete |
ServiceProgrammeTemplate
Auto-upserted when a session with saveAsTemplate = true completes. Minister assignments are always blank — only structure is saved.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | varchar | e.g. “First Service” |
| serviceSlotName | varchar | Match key for auto-suggestion |
| slots | jsonb | [{ position, type, topic, allocatedMinutes }] |
| createdFrom | ServiceProgramme | null | SET NULL on delete |
ServiceHeadcount
Physical attendance count record for one service slot, broken down by demographic group.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| serviceSlot | ServiceSlot | OneToOne (unique), CASCADE on delete |
| maleAdults | int | Default 0 |
| femaleAdults | int | Default 0 |
| teenagers | int | Default 0 |
| children | int | Default 0 |
| mobileChurch | int | Default 0 — count from the mobile outreach venue (fixed group) |
| customGroups | jsonb | Record<string, number> — extensible free-form groups |
| recordedBy | Admin | null | ManyToOne, SET NULL on delete — admin who submitted the record |
| notes | text | null | Optional context note for the record |
Computed field: total is not stored. It is computed on every read as the sum of all five fixed columns plus all values in customGroups. The value is appended to each response object.
PrayerProgram
A named, configurable prayer program. All prayer entities (day configs, rules, meetings, roster entries) are scoped to a program, enabling multiple concurrent programs (e.g. “Morning Intercessory” for workers and “Friday Night” open to all members).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | string | |
| description | text | null | Optional |
| audience | PrayerAudience | WORKERS | MEMBERS | ALL — controls who may be assigned/self-select |
| selectionWindowDays | int | Days before meeting when self-selection opens. Default 7. |
| isActive | boolean | Inactive programs are excluded from normal operations. Default true. |
Audience rules: WORKERS-audience programs use auto-assign; MEMBERS-audience programs use self-selection and manual assignment only; ALL programs combine both.
PrayerScheduleConfig
Global configuration for the prayer roster module (legacy — predates multi-program support). One active record at a time. New installations use PrayerProgram.selectionWindowDays instead.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| selectionWindowDays | int | Number of days before a meeting that self-selection is open. Default 7. |
| isActive | boolean | Only one active config is used at a time |
PrayerDayConfig
Defines which days of the week prayer meetings occur and their capacity/mode, scoped to a program.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| program | PrayerProgram | ManyToOne, RESTRICT on delete. Indexed. |
| dayOfWeek | int | 0 = Sunday … 6 = Saturday (JS Date.getDay) |
| mode | PrayerDayMode | PHYSICAL | VIRTUAL |
| startTime | string (HH:mm) | Default 00:00 |
| endTime | string (HH:mm) | Default 01:00 |
| maxCapacity | int | Max assignees for this day |
| isActive | boolean | Inactive configs are skipped during generation |
Unique constraint (application-level): Only one active config per (program, dayOfWeek) pair.
PrayerScheduleRule
Configurable rules that govern frequency and capacity requirements, scoped to a program.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| program | PrayerProgram | ManyToOne, RESTRICT on delete. Indexed. |
| type | PrayerRuleType | ROLE_FREQUENCY | MIN_LEADERS_PER_MEETING | MAX_PER_MEETING |
| targetLeadType | DepartmentLeadTypeEnum | null | null = applies to all workers; set for HOD/D_HOD overrides |
| value | int | Times per month for ROLE_FREQUENCY; head-count for others |
| description | string | Human-readable label |
| isActive | boolean | Inactive rules are ignored during assignment |
Seeded defaults (on the default program): worker frequency = 1, HOD frequency = 2, D_HOD frequency = 2, min leaders per meeting = 1, max per meeting = 5.
PrayerFixedAssignment
Permanently pins a worker to a specific prayer day across all months, within a program.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| workerProfile | WorkerProfile | ManyToOne, CASCADE on delete |
| dayConfig | PrayerDayConfig | ManyToOne, CASCADE on delete |
| isActive | boolean | Soft-disable without deleting the assignment |
Unique constraint: (workerProfile, dayConfig) — one fixed assignment per worker per day config.
PrayerMeeting
One concrete meeting per calendar date generated from a day config, scoped to a program.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| program | PrayerProgram | ManyToOne, RESTRICT on delete. Indexed. |
| date | string (YYYY-MM-DD) | Actual meeting date. Indexed. |
| month | int | Calendar month (1–12). Indexed. |
| year | int | Calendar year. Indexed. |
| dayConfig | PrayerDayConfig | ManyToOne, RESTRICT on delete |
| status | PrayerMeetingStatus | SCHEDULED | COMPLETED | CANCELLED. Indexed. |
| selectionStatus | PrayerWindowStatus | PENDING | OPEN | CLOSED. Indexed. |
| currentCapacity | int | Current number of assigned workers/members |
| rosterEntries | PrayerRosterEntry[] | OneToMany |
PrayerRosterEntry
One assignment of a worker or member to a prayer meeting. Exactly one of workerProfile or member is set; the other is null.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| workerProfile | WorkerProfile | null | ManyToOne, CASCADE on delete. Indexed. Null for member-only assignments. |
| member | Member | null | ManyToOne, CASCADE on delete. Indexed. Null for worker assignments. |
| meeting | PrayerMeeting | ManyToOne, CASCADE on delete. Indexed. |
| assignmentType | PrayerAssignmentType | FIXED | SELF_SELECTED | AUTO_ASSIGNED | MANUAL |
| status | PrayerRosterStatus | SCHEDULED | RESCHEDULED |
| rescheduledFrom | PrayerRosterEntry | null | Self-referencing nullable FK, SET NULL on delete — tracks origin of rescheduled entries |
| reminderTwoDaySent | boolean | 2-day-ahead reminder dispatched flag. Indexed (scheduler filter). |
| reminderDaySent | boolean | Day-of reminder dispatched flag. Indexed (scheduler filter). |
RentalFacility
A bookable space (hall, room, etc.) owned by the congregation.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | varchar | Unique display name |
| description | text | nullable |
| basePrice | decimal | Price before discount (15,2) |
| capacity | int | nullable — max occupancy |
| isActive | boolean | Soft-disable without deleting |
RentalPricingTier
One discount rule per member category. Unique on memberCategory.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| memberCategory | RentalMemberCategory | MEMBER | WORKER | LEADER | PUBLIC — UNIQUE |
| discountType | RentalDiscountType | PERCENTAGE | FLAT |
| discountValue | decimal | % value or flat amount (10,2) |
| isActive | boolean |
RentalAddon
Bookable extras (LED screen, décor, etc.) with an optional asset link.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| name | varchar | |
| description | text | nullable |
| price | decimal | Service charge, subject to discount (15,2) |
| cautionAmount | decimal | Refundable deposit — never discounted (15,2) |
| isActive | boolean | |
| asset | Asset | nullable FK → assets, SET NULL on delete |
RentalCalendarBlock
Admin-created blackout period on a facility (maintenance, church events, etc.).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| facility | RentalFacility | ManyToOne, CASCADE on delete |
| startDateTime | timestamptz | |
| endDateTime | timestamptz | |
| reason | text | nullable |
RentalBooking
A member’s booking of a facility for a specific time window. Price snapshot is stored at creation time so later config changes do not affect existing bookings.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| facility | RentalFacility | ManyToOne, RESTRICT on delete |
| member | Member | ManyToOne, RESTRICT on delete |
| startDateTime | timestamptz | Indexed |
| endDateTime | timestamptz | |
| status | RentalBookingStatus | PENDING → CONFIRMED → IN_PROGRESS → COMPLETED | CANCELLED | REJECTED |
| memberCategory | RentalMemberCategory | Snapshot of category at booking time |
| basePrice | decimal | Snapshot of facility base price |
| discountType | RentalDiscountType | nullable — applied discount type |
| discountValue | decimal | nullable — applied discount amount/percent |
| discountSource | RentalDiscountSource | NONE | TIER | OVERRIDE |
| serviceFee | decimal | (base + addons) after discount |
| cautionTotal | decimal | Sum of all caution amounts — never discounted |
| grandTotal | decimal | serviceFee + cautionTotal |
| overrideDiscountType | RentalDiscountType | nullable — admin override |
| overrideDiscountValue | decimal | nullable |
| overrideDiscountNote | text | nullable — reason for override |
| purpose | text | nullable |
| notes | text | nullable — admin notes |
| rejectionReason | text | nullable |
RentalBookingAddon
Junction between a booking and selected add-ons. Stores unit price/caution snapshots.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| booking | RentalBooking | ManyToOne, CASCADE on delete |
| addon | RentalAddon | ManyToOne, RESTRICT on delete |
| quantity | int | Default 1 |
| unitPrice | decimal | Snapshot of addon.price at booking time |
| unitCaution | decimal | Snapshot of addon.cautionAmount |
RentalPayment
One payment line per booking (service fee + separate caution record if caution > 0). Tracks proof and refund lifecycle.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| booking | RentalBooking | ManyToOne, CASCADE on delete. Indexed. |
| type | RentalPaymentType | SERVICE_FEE | CAUTION |
| amount | decimal | |
| status | RentalPaymentStatus | PENDING → PAID; CAUTION can transition to REFUNDED |
| paidAt | timestamptz | nullable |
| refundedAt | timestamptz | nullable — set when caution returned |
| reference | varchar | nullable — bank ref / receipt number |
| proofUrl | varchar | nullable |
Game
A reusable Kahoot-style quiz definition — created on the admin portal, can back multiple GameSessions over time.
department/churchClass are categorization only, not access control (see Games Module).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| title | varchar | |
| description | text | null | |
| status | GameStatusEnum | DRAFT | LIVE_SESSION_ACTIVE | ARCHIVED (ARCHIVED not yet exposed via any endpoint) |
| createdBy | Admin | null | ManyToOne, SET NULL on delete |
| department | Department | null | ManyToOne, SET NULL on delete — reporting/filtering only |
| churchClass | ChurchClass | null | ManyToOne, SET NULL on delete — reporting/filtering only |
GameQuestion
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| game | Game | ManyToOne, CASCADE on delete. Indexed. |
| order | int | Zero-based display/play order within the game |
| questionText | text | |
| options | jsonb | Array of option strings, minimum 2 |
| correctOptionIndex | int | Index into options; validated on create/update |
| points | int | Default 1000 — base score before the speed bonus |
| timeLimitSeconds | int | Default 20 |
GameSession
One “run” of a Game. sessionCode is the join credential (GAME-XXXXXX).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| game | Game | ManyToOne, CASCADE on delete. Indexed. |
| sessionCode | varchar, unique | |
| status | GameSessionStatusEnum | SCHEDULED | LIVE | ENDED (SCHEDULED not yet reachable — sessions start directly into LIVE) |
| hostAdmin | Admin | null | ManyToOne, SET NULL on delete — the only admin who can advance questions (see Games Module — ending a session is deliberately NOT host-restricted) |
| currentQuestionIndex | int | null | Null before start |
| currentQuestionStartedAt | timestamptz | null | Server-side clock all participants are scored against |
| startedAt / endedAt | timestamptz | null |
GameParticipant
A member’s membership in one GameSession, with their running score. @Unique(['session','member']) — joining
again just returns the existing row.
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| session | GameSession | ManyToOne, CASCADE on delete. Indexed. |
| member | Member | ManyToOne, CASCADE on delete |
| totalScore | int | Default 0; incremented per correct response |
GameResponse
One row per (session, question, participant) answer — the unique constraint is the DB-level backstop against double-answering. Individual responses are not audit-logged (too high-frequency/low-stakes).
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| session | GameSession | ManyToOne, CASCADE on delete. Indexed. |
| question | GameQuestion | ManyToOne, CASCADE on delete |
| participant | GameParticipant | ManyToOne, CASCADE on delete |
| selectedOptionIndex | int | |
| isCorrect | boolean | |
| pointsAwarded | int | 0 for incorrect; speed-weighted for correct (see Games Module) |
| answeredAt | timestamptz |
Unique constraint: (session, question, participant).
4. Authentication & Authorization
Dual-Surface Sessions
The app has two independent entry points — the mobile app (POST /auth/login) and the admin portal (POST /auth/admin-login) — and each maintains its own session row in member_sessions. The surface column (MEMBER | ADMIN) is the discriminator; a unique constraint on (member_id, surface) ensures at most one active session per surface per user.
JWT payload now includes aud (audience) to identify the surface:
{ "sub": "<memberId>", "role": "MEMBER|WORKER", "aud": "MEMBER|ADMIN" }
Surface enforcement:
JwtStrategycallsvalidateAccessToken(sub, aud)— it looks up the session row for that specific(memberId, surface)pair. An admin token used on a mobile endpoint checks theADMINsession; if the user has no admin session, the request is rejected with 401.AdminGuardadditionally checksrequest.user.surface === 'ADMIN'. A member token (aud: MEMBER) used on an admin-portal endpoint is rejected with 403 before the admin DB lookup even runs.POST /auth/logoutis surface-scoped: it readsreq.user.surfacefrom the validated token and deletes only that session row, leaving the other surface’s session intact.- Password reset and device reset/purge invalidate both surfaces simultaneously (credential change = full sign-out).
There is no ADMIN role in the JWT. Admin portal access is determined at the route level by AdminGuard looking up the admins table.
validateAccessToken returns MemberAuth which is set as req.user. For WORKER-role members, req.user.workerProfileId is populated from the loaded workerProfile.id. Worker-facing endpoints that need the worker’s profile ID read it from req.user.workerProfileId — this is never embedded in the JWT itself. HOD status is not carried on MemberAuth; it is resolved once on GET /auth/me (see above) and can be cached by the client. Server-side HOD-gated endpoints query department_leads directly when they need it.
Guards
- ThrottlerGuard — applied globally via
APP_GUARD. Rate-limits every endpoint toTHROTTLE_LIMITrequests perTHROTTLE_TTL_MS-millisecond window per IP (defaults: 100 req / 60 s). Returns HTTP 429 when the limit is exceeded. TheGET /healthendpoint is exempt via@SkipThrottle().GET /service-session/:code/stateandGET /service-session/:code/slots/:positionare overridden to 300 req/60s via@Throttle()— these are public, read-only routes; the override exists for the initial page-load fetch and the (now much less frequent) safety-net poll each live-session view keeps as a fallback — see the Socket.IO section below for why per-IP throttling was never the real scaling lever for this module, since a per-IP cap does nothing to bound aggregate load across the hundreds of distinct devices/IPs a single popular session’s Audience view can attract. - JwtAuthGuard — applied globally via
APP_GUARD. All routes are protected unless decorated with@Public(). - PasswordChangeRequiredGuard — applied globally via
APP_GUARD(runs afterJwtAuthGuard). Blocks all requests with HTTP 403PASSWORD_CHANGE_REQUIREDif the authenticated user haschangedPassword = false(i.e. they are on a system-generated temporary password). Exempt routes must be decorated with@SkipPasswordChangeCheck():POST /auth/refresh,POST /auth/logout,GET /auth/me,POST /auth/change-password. - RolesGuard — applied per-route via
@Roles(MemberRoleEnum.WORKER). Checksrequest.user.rolefor worker-only routes (mobile app). - AdminGuard — applied per-route via
@UseGuards(AdminGuard). First checksrequest.user.surface === 'ADMIN'(rejects member tokens with 403), then queries theadminstable to verify an active Admin record, then checks@RequiresPermission(AdminPermission.X)metadata. Setsrequest.adminfor downstream use. Used exclusively on admin portal routes. - LocalAuthGuard — used on
POST /auth/login(mobile) andPOST /auth/admin-login(web portal) to invoke the Passport local strategy. - RefreshJwtAuthGuard — used on
POST /auth/refresh.
Token Lifecycle
Refresh token delivery and transport differ by surface:
| Surface | Refresh token on login | Refresh on POST /auth/refresh |
Logout |
|---|---|---|---|
| ADMIN (web portal) | Set as httpOnly; SameSite cookie on /v1/auth/refresh path only — not in the response body |
Cookie sent automatically by browser; new cookie set in response | Cookie cleared |
| MEMBER / WORKER (mobile) | Returned in response body (refresh_token) |
Sent in Authorization: Bearer header |
Session row cleared |
- Login → receives
access_token+requires_password_change(andrefresh_tokenin body for mobile only). A surface-scoped session row is created (or updated) with a hashed refresh token. Ifrequires_password_changeistrue, the client must redirect the user toPOST /auth/change-passwordbefore allowing any other action. - Access token expires → call
POST /auth/refresh. Admin web clients rely on the httpOnly cookie (sent automatically); mobile clients send the refresh token in theAuthorization: Bearerheader. The refresh token carriesaudand renews the same-surface session. - Logout → clears the session row for the caller’s surface. For the ADMIN surface the httpOnly cookie is also cleared. The other surface’s session is unaffected.
Admin cookie secure/sameSite flags are NODE_ENV !== 'development', not NODE_ENV === 'production'. The admin web app calls the API cross-site with withCredentials: true, which requires secure: true; sameSite: 'none' — browsers drop any cookie without those flags on a cross-site request. Railway always serves over HTTPS regardless of environment name, so test/staging deployments need the same secure cookie behavior as production; only local dev runs over plain HTTP and needs the relaxed lax/non-secure cookie. Checking === 'production' previously meant any other deployed NODE_ENV value (e.g. test) silently downgraded to a cookie that cross-site browsers refuse to send back on refresh, dropping the admin session on every refresh call.
Refresh Token Rotation & Reuse Detection
Every call to POST /auth/refresh performs a full rotation:
- A new refresh token is issued and its hash replaces the previous one in
member_sessions. - The previous hash, plus the full token response that was just issued and the rotation timestamp, are stored together in Redis under
rt_rotated:{memberId}:{surface}for the duration of the refresh token’s TTL. - If an already-rotated token is presented (i.e. the hash matches the Redis entry but not the current session hash), the server checks how long ago the rotation happened:
- Within the reuse grace window (10s) — treated as a benign concurrent-request race, not theft (e.g. two browser tabs on the same admin login, or the proactive pre-expiry refresh racing a reactive 401-triggered refresh). The server does not rotate again or touch the session; it replays the exact tokens issued by the rotation that already happened, so both callers converge on the same valid pair.
- Outside the grace window — treated as credential reuse, the server immediately invalidates the entire session for that surface, and returns HTTP 401. This limits the blast radius of a stolen refresh token to a single use.
- On reuse detection (outside the grace window) the member receives a
session-security-alertemail advising them to change their password if the sign-out was unexpected. - If the member row disappears before the rotated session is saved, the matching
member_sessions.member_idforeign-key violation returns HTTP 401 with a sign-in-again message. Other database errors are not converted.
Absolute Session Lifetime
Each session row in member_sessions is upserted per member + surface — updateLogin() reuses the existing row across logins rather than creating a new one, updating only hashedRefreshToken/lastLogin/lastLogout. createdAt (from BaseEntity) is therefore set once at the row’s first-ever login and never moves again — it is not a valid anchor for “how long has this login been going.” On every refresh request, validateRefreshToken checks:
Date.now() - session.lastLogin > SESSION_MAX_AGE_DAYS × 86 400 000 ms
(Anchored on lastLogin, which resets on every login, not createdAt — using createdAt would mean any member whose session row is older than SESSION_MAX_AGE_DAYS gets force-logged-out on the very first refresh after every future login, no matter how recently they signed in.)
If the threshold is exceeded the session is invalidated and HTTP 401 is returned, forcing a fresh login regardless of how recently the token was rotated. SESSION_MAX_AGE_DAYS defaults to 30 and is configurable via environment variable.
Temporary Password Flow
All new accounts — whether created via signup or admin-elevated — receive a server-generated temporary password. The
changedPassword flag on Member is set to false. On first login:
- The login response includes
"requires_password_change": true. - The
PasswordChangeRequiredGuardblocks every subsequent authenticated request except the four exempt routes above. - The user must call
POST /auth/change-password(supplying the emailed temporary password asoldPassword) to activate full access. - Once changed,
changedPasswordis set totrueand normal access resumes.
Signup: POST /auth/signup no longer accepts a password field. The server generates a secure random password,
hashes it, sets changedPassword = false, and emails the plaintext temporary password to the new member.
The member app’s signup is a single screen — first/last name, email, optional phone, gender and birthday. Marital
status and the church journey (dateJoinedChurch, yearBornAgain, yearBaptized, baptizedWithHolyGhost) are
added afterwards from Edit Profile, prompted by a “Next Steps” card on Home. SignupDto still accepts every field
(older clients, admin create); joinWorkforce: true now records serveInterestAt instead of being discarded.
Device Lock (Mobile App)
Only one device may be logged into the mobile app per member account. This prevents proxy check-ins.
POST /auth/loginrequires adeviceIdstring in the request body (the mobile client’s device fingerprint).- On the member’s first login (
member.deviceIdisnull), the device is registered and login succeeds. - On subsequent logins, the incoming
deviceIdis compared to the stored value. If they match, login succeeds. If they differ, HTTP 403 is returned. - An admin can purge the device lock via
DELETE /admin/members/:id/device(MEMBERS_WRITEpermission). This setsdeviceId = nulland invalidates all active sessions for that member, forcing a fresh login from any device. POST /auth/admin-login(web portal) does not perform a device check — it is web-first.
Bug fixed: the login-notification email (EmailCategory.LOGIN_ALERT, subject “New … Login Detected”) used to
send on every successful login, not just a new-device registration — despite the email itself claiming “We
detected a new login.” AuthService.login() now captures isNewDeviceRegistration = !member.deviceId before
setDeviceId() mutates it, and only queues the email when that’s true — i.e. this member’s first-ever login, or
their first login after a device reset (the only two ways deviceId transitions from null). Every other login
(the normal case: deviceId already matches) no longer emails at all. The EmailCategorySettingsService/
ENFORCE_DISTANCE_CHECK-style tenant-level toggle for LOGIN_ALERT (see “Email Category Settings Module”) still
applies on top of this — it can suppress the email entirely for a tenant, but doesn’t affect when within a
tenant it would have fired.
Self-Service Device Reset Flow
A member who needs to log in from a new device (lost phone, factory reset, etc.) can reset their own device lock without admin involvement, subject to a rate limit.
POST /auth/device-reset/request— accepts{ email, newDeviceId }. Rate-limited per email (default: 3 attempts per 24-hour window, configurable viaDEVICE_RESET_MAX_ATTEMPTSandDEVICE_RESET_WINDOW_SECONDS). Generates a 6-digit OTP, stores an Argon2 hash and thenewDeviceIdindevice_reset_otps, and emails the code. Always returns the same success message to avoid leaking account existence.- Security note:
newDeviceIdis locked in at request time. An attacker who intercepts the OTP cannot redirect the reset to their own device — the device is bound to whoever initiated the request.
- Security note:
POST /auth/device-reset/verify— accepts{ email, otp }. Verifies the OTP, checks expiry, marks the record as used, updatesmember.deviceIdto thenewDeviceIdstored on the OTP record, invalidates all active sessions, unsubscribes push, revokes every one of the member’s WebAuthn credentials (WebauthnService.revokeAllCredentials), and sends a confirmation email. On success the member must log in fresh from the new device, re-enrolling biometrics if they want one-tap login again.- Why WebAuthn credentials are revoked too: a WebAuthn credential is hardware-bound and deliberately never
checks
deviceId(seeloginWithWebauthn’s own comment) — several trusted devices are meant to each hold their own credential. Without this, a lost/stolen device’s fingerprint or Face ID would keep working right through a device reset, since that lock only ever gated the password path. There’s no way to isolate which single stored credential belongs to the lost device, so a device reset revokes all of them rather than leaving any possibly compromised one live. - If the attempt count reaches the configured maximum, the email is rate-limited and the member must contact an
admin for an out-of-band device purge (
DELETE /admin/members/:id/device). - Wrong-guess limiting: see “OTP Verify Guess Limiting” below — this endpoint is one of the three protected.
- Why WebAuthn credentials are revoked too: a WebAuthn credential is hardware-bound and deliberately never
checks
OTP Verify Guess Limiting
FORGOT_PASSWORD_MAX_ATTEMPTS/DEVICE_RESET_MAX_ATTEMPTS (and the per-route @Throttle decorators) only cap how
often a new OTP can be requested — none of them capped how many guesses could be made against an OTP that was
already issued. A 6-digit code has only 1,000,000 possible values, so without a separate per-account guess limit a
distributed attacker (rotating source IPs past the per-IP @Throttle) could brute-force a live code within its
OTP_TTL_SECONDS validity window.
AuthService.checkOtpVerifyRateLimit/recordFailedOtpVerify/clearOtpVerifyRateLimit add a per-account counter
(Redis key otp_verify_fail:<identifier>:<scope>, TTL = OTP_TTL_SECONDS) on top of the existing request-side
limits — mirrors checkLoginRateLimit’s shape. Every wrong or expired/missing-code attempt increments the counter;
a correct verify clears it. Once OTP_VERIFY_MAX_ATTEMPTS (default 5) failed attempts accumulate, the endpoint
returns 429 TOO_MANY_REQUESTS — the account must wait out the window or request a fresh OTP (which doesn’t reset
this counter, only reissuing the underlying code does something new to guess). Applies independently, keyed per
scope, to all three OTP-verify endpoints:
POST /auth/reset-password(scope: 'password_reset', identifier: email)POST /auth/device-reset/verify(scope: 'device_reset', identifier: email)POST /auth/email-change/confirm(scope: 'email_change', identifier: member id) — this route previously had no@Throttleat all; it now has the same5/minper-IP throttle as the other two, plus this per-account guard.
Forgot Password / OTP Reset Flow
POST /auth/forgot-password— rate-limited (default: 3 attempts per hour, configurable via env). Generates a 6-digit OTP, stores an Argon2 hash inpassword_reset_otps, and emails the code. Always returns the same success message to avoid leaking account existence.POST /auth/reset-password— rate-limited (5 attempts/min, same asforgot-password). Verifies the OTP against the hash, checks expiry (default: 15 min for a self-requested reset — longer for a tenant-welcome OTP, see below), marks the OTP as used, updates the password, invalidates any existing session, and emails a confirmation. On success the user must log in fresh.- Wrong-guess limiting: see “OTP Verify Guess Limiting” above.
This endpoint is also how a brand-new tenant’s first admin sets their initial password — see “Tenant Welcome / Set Password Flow” below.
Password requirements (enforced identically on login, reset, change-password, and platform-admin reset — each
DTO declares its own class-validator rules rather than sharing one, so the two identity systems stay independently
changeable): minimum 8 characters, at least one uppercase letter, at least one number, and at least one non-alphanumeric
character (/[^A-Za-z0-9]/ — any symbol qualifies, not a fixed whitelist). The special-character check was widened
from a fixed set (@$!%*?&) to any symbol so that passwords generated by browser/OS password managers (which often
pick a symbol outside a narrow whitelist) aren’t rejected.
Self-Service Email Change Flow
A logged-in member/worker can change their own email address without admin involvement. Unlike the forgot-password
and device-reset flows above, both routes require an authenticated session (JwtAuthGuard via the global guard, no
@Public()) — the OTP is a second factor confirming ownership of the new mailbox, not a way to prove account
ownership from scratch.
POST /auth/email-change/request— accepts{ newEmail }. Returns409 ConflictifnewEmailis already used by another member. Rate-limited the same way asforgot-password(checkOtpRateLimit, keyed on the caller’s member id). Deletes any prior unused request, generates a 6-digit OTP, stores an Argon2 hash and the targetnewEmailinemail_change_otps(mirrorsDeviceResetOtp’s pattern of locking in the sensitive value at request time), and emails the code to the new address (not the current one) — this doubles as proof the caller controls it.POST /auth/email-change/confirm— accepts{ otp }. Verifies the OTP and expiry (OTP_TTL_SECONDS) against the caller’s own most recent unused record, re-checks thatnewEmailis still unclaimed (409on a race), marks the record used, updatesmember.emailto the storednewEmail, and emails a confirmation to the new address.- Wrong-guess limiting: see “OTP Verify Guess Limiting” above.
Tenant Welcome / Set Password Flow
Neither public entry point — self-serve POST /signup nor platform-admin POST /platform/tenants — collects a
password for the tenant’s first admin. SignupDto has no adminPassword field at all: letting an unauthenticated
signup form set a password directly would mean anyone could submit an email address they don’t own, with no proof of
control over that inbox. TenantProvisioningService.provision()/seedTenantAdmin() still branches on whether
adminPasswordHash was supplied, but in practice only one caller ever supplies it today — the provision:tenant CLI
script (src/provision-tenant.ts), an internal ops tool run by trusted staff, not a public HTTP surface:
- No
adminPasswordHashsupplied (bothPOST /signupandPOST /platform/tenants, i.e. the normal case):seedTenantAdmin()generates a random password viaUtilityService.generateRandomPassword()and hashes it — this password is never revealed to anyone, including the caller who triggered provisioning.changedPassword: false. It also generates a 6-digit OTP and stores its hash inpassword_reset_otps(the same table the forgot-password flow uses) with a 48-hour expiry (WELCOME_OTP_TTL_HOURS) — deliberately longer than the 15-minute forgot-password OTP, since a new admin may not check their email the same day. Once the tenant is active,provision()fire-and-forgets a warm welcome email (tenant-welcometemplate) to the new admin containing the OTP and adiscuva-admin(formerlyFaithapp-admin)/set-password?email=...&otp=...link. That page pre-fills the code and calls the existingPOST /auth/reset-password— the same endpoint and verification logic the forgot-password flow uses, just reached from a different starting point. The longer OTP window is offset by rate-limitingPOST /auth/reset-passworditself (5 attempts/min). Clicking that link and setting a password is also, in effect, email verification — nobody can ever log in to a self-serve-signed-up tenant without proving control of the inbox behindadminEmail. adminPasswordHashsupplied (CLI only):changedPassword: true, no OTP generated, no welcome email sent.
Async Tenant Provisioning + Onboarding State Machine
TenantProvisioningService.provision() (CREATE SCHEMA + run the tenant migration set + seed the first admin +
create a Subscription) runs async, on a Bull queue (TENANT_PROVISIONING_QUEUE,
TenantProvisioningProcessor) for self-serve POST /signup only. POST /platform/tenants runs it inline —
see “Platform-admin tenant creation is synchronous” below for why the two callers deliberately differ. provision()
itself is unchanged either way and still callable directly (the provision:tenant CLI script does) — it’s already
idempotent (checks existing state before acting at every step), which is what makes it safe for the queue to retry
and safe to re-run by hand after a synchronous failure.
Tenant.onboardingStatus (PENDING | AWAITING_APPROVAL | PROVISIONING | ACTIVE | FAILED) is orthogonal to isActive — isActive
still means “currently allowed to serve live traffic” (also flipped by PlatformTenantService.suspendTenant);
onboardingStatus only ever moves forward through the lifecycle once and never changes on suspend/reactivate.
Deleting an invalid/incomplete signup: DELETE /platform/tenants/:id (PlatformTenantService.deleteTenant,
TENANTS_DELETE permission) is the only hard-delete path for a tenant, and it’s deliberately narrow — only
PENDING (never provisioned) or FAILED (provisioning attempt died) tenants qualify; an ACTIVE one, even later
suspended, is refused with 409 since that’s real church data. Drops the Postgres schema first (DROP SCHEMA IF EXISTS "..." CASCADE, safe as a no-op for a PENDING tenant that never reached provision()), then removes the
tenants row, which cascades onboarding events/subscription/etc. via existing FKs. No automated sweep for
abandoned signups exists yet — this is a manual action from the discuva-platform tenant detail panel, gated behind
a “type the subdomain to confirm” step given it’s the one irreversible tenant action.
Subdomain validation happens inside ensurePendingTenant, before touching the database, against two separate
blocklists — 403/409 ConflictException either way, but for different reasons: RESERVED_SUBDOMAINS
(src/tenant/utility/extract-subdomain.ts — www/api/admin/platform/app) are words that would actually
break routing if claimed, blocked for every caller including platform admins; GENERIC_OR_ABUSE_PRONE_SUBDOMAINS
(src/tenant/constants/blocked-signup-subdomains.constant.ts — test/dev/demo/staging/login/billing/etc.,
grouped by reason in the file itself) is a policy call against free-tier squatting and phishing-adjacent names, and
can be bypassed via ensurePendingTenant’s 4th param (allowGenericSubdomain) — set only by
PlatformTenantService.createTenant, since a platform admin deliberately creating e.g. a real sales-demo tenant at
demo.<domain> is a trusted, authenticated action, not the abuse case this list exists to stop.
Self-serve signup flow (async):
TenantProvisioningService.ensurePendingTenant(subdomain, churchName, parentTenantId?)— the find-or-create part of the oldprovision(), now its own method — creates theTenantrow (onboardingStatus: PENDING) or returns the existing one if resuming. Callable standalone specifically soSignupControllergets a realtenant.idback before handing off to the queue.recordEvent(tenantId, 'SIGNUP_INITIATED', SELF_SERVE)— see the audit trail note below.- A
TENANT_PROVISIONING_JOBis enqueued (ProvisionTenantParams+tenantId+actorType/actorId+branchInviteToken),attempts: 3, backoff: { type: 'exponential', delay: 5000 }(this codebase’s standard retry convention, e.g.TitheProcessor). TenantProvisioningProcessorsetsonboardingStatus = PROVISIONING, recordsPROVISIONING_STARTED, callsprovision()(unchanged), then on success setsonboardingStatus = ACTIVE(provision()already flipsisActive), recordsPROVISIONING_COMPLETED, and consumes the branch invite (BranchInviteService.markAccepted, moved here fromSignupControllersince the controller no longer awaits completion). On permanent failure (all 3 attempts exhausted, via@OnQueueFailed()) setsonboardingStatus = FAILEDand recordsPROVISIONING_FAILEDwith the error message inmetadata.GET /signup/:tenantId/status(@Public()) — polled by the caller untilstatusisACTIVE(orFAILED). Unauthenticated by design, same reasoning asPOST /signupitself; excluded fromTenantMiddlewarealongside it inTenantModule(a caller may not be on the tenant’s own subdomain yet — e.g. a marketing site). Confirmed live this exclude needs a named path parameter (v1/signup/:tenantId/status), not a bare(.*)wildcard mid-path — this project’spath-to-regexpversion throwsPathErroron boot for that shape; only a suffix wildcard likev1/platform/(.*)is accepted.
Platform-admin tenant creation is synchronous. PlatformTenantService.createTenant() calls
ensurePendingTenant() + recordEvent('PLATFORM_ADMIN_INITIATED', PLATFORM_ADMIN, { actorId }), then calls
provision() directly and awaits it inline — no queue, no polling. On success it sets onboardingStatus = ACTIVE
and records PROVISIONING_COMPLETED; on failure it sets onboardingStatus = FAILED, records
PROVISIONING_FAILED with the error in metadata, and rethrows so the platform admin sees the real error
immediately instead of a silently-stuck PENDING row. This was deliberately reverted from an earlier async design:
unlike self-serve signup, this is a trusted, authenticated action by a platform admin, there’s no fraud-review gate
that would need to sit between “created” and “actually provisioned,” and CREATE SCHEMA + migrations + seeding is
fast enough that the admin can just wait for the response.
Platform-level audit trail (TenantOnboardingEvent, tenant_onboarding_events): distinct from
AuditLogService, which is tenant-scoped (lives in each church’s own schema, actor FKs to that tenant’s own
Member) and can’t record an event from before/independent of any tenant schema existing. A small, purpose-built,
public-schema table instead: tenant (FK, cascade), event (SIGNUP_INITIATED | PLATFORM_ADMIN_INITIATED | AWAITING_APPROVAL | APPROVED | PROVISIONING_STARTED | PROVISIONING_COMPLETED | PROVISIONING_FAILED), actorType
(SELF_SERVE | PLATFORM_ADMIN | SYSTEM), actorId (nullable — the platform admin’s id when actorType = PLATFORM_ADMIN), metadata (nullable jsonb). Written via TenantProvisioningService.recordEvent() — no separate
service, it’s a simple insert-only log. Viewable per-tenant via GET /platform/tenants/:id/onboarding-events
(TENANTS_READ permission). The platform-admin path never emits PROVISIONING_STARTED — there’s no meaningful gap
between “initiated” and “started” when both happen inline in the same request.
Response shape: POST /signup returns a PENDING (or AWAITING_APPROVAL, see below) tenant immediately (poll
GET /signup/:tenantId/status for completion). POST /platform/tenants returns the tenant already ACTIVE — same
shape GET /platform/tenants’ rows use, no polling needed.
Manual Approval Gate for Self-Serve Signups
PlatformSettingKey.SELF_SERVE_REQUIRES_APPROVAL (boolean, default off — see Platform Settings below) inserts a
review step between a cold self-serve POST /signup and real provisioning, to stop demo/test/abuse signups from
auto-provisioning unattended. When on:
SignupController.signup()still creates thePENDINGTenantrow and recordsSIGNUP_INITIATEDexactly as before, but — for a genuine cold signup only, see below — callsTenantProvisioningService.holdForApproval()instead of enqueueing the provisioning job. That setsonboardingStatus = AWAITING_APPROVAL, persists the signup detailsprovision()will eventually need onto the newTenant.pendingSignupParamsjsonb column (admin name/email, plan, branch-invite linkage — these normally only ever live transiently in the queue job payload, which doesn’t work here since approval could happen an unpredictable amount of time later), records anAWAITING_APPROVALonboarding event, and emails every active platform admin who holdsTENANTS_WRITE(viatenant-approval-needed.html, a new platform-level template alongsideplatform-admin-welcome.html— same “no tenant in CLS context” branding fallback) a link to the Tenants page.TenantMiddlewaretreatsAWAITING_APPROVALidentically toPENDING/PROVISIONING— a site visitor sees the same “still being set up” 503, not a different message; only the platform-admin console needs the distinct state.- A platform admin reviews the held signup from its detail panel in discuva-platform and either:
- Approves —
PATCH /platform/tenants/:id/approve(TENANTS_WRITE,PlatformTenantService.approveTenant): 404/409 unless the tenant is actuallyAWAITING_APPROVAL, reconstructs the provisioning job frompendingSignupParams, records anAPPROVEDevent (actorType: PLATFORM_ADMIN), and enqueues it via the sameTenantProvisioningService.enqueueProvisioning()both this andSignupControllercall — provisioning stays async even here, since this is still fundamentally a self-serve signup being released, not a platform admin directly creating one (POST /platform/tenantsstays instant and untouched by this gate entirely). - Rejects — reuses
DELETE /platform/tenants/:id(see “Deleting an invalid/incomplete signup” above), whose allowed-status set now includesAWAITING_APPROVALalongsidePENDING/FAILED.
- Approves —
A branch invite bypasses this gate even when the toggle is on — accepting an invite already required a parent
tenant’s own admin to generate the token, so it’s an invitation-only path already vetted once; gating it a second
time behind generic approval would be redundant friction on top of vetting that already happened. Only a cold,
anonymous signup (resolvedInvite unset in SignupController.signup()) is ever held.
Why TenantProvisioningService now owns TENANT_PROVISIONING_QUEUE/TENANT_PROVISIONING_JOB/
TenantProvisioningJobData (moved from tenant-provisioning.processor.ts, which now imports them back): both
SignupController and PlatformTenantService.approveTenant() need to construct and enqueue a provisioning job, and
the processor already imported TenantProvisioningService — defining the job-payload contract there too instead of
keeping it on the processor avoids a circular file import between the two.
Founder Welcome Email (FounderWelcomeEmailScheduler)
A personal, one-time note from Discuva’s founder, sent 1 day after a tenant first reaches ACTIVE — every
activation path (self-serve, platform-admin-created, an approved signup, a branch), not just self-serve. Deliberately
separate from the transactional tenant-welcome email (the set-password link, sent immediately at provisioning) —
this exists purely to feel human, not to drive an action.
Two new Tenant columns: activatedAt (set exactly once, at the same two call sites that set
onboardingStatus = ACTIVE and record PROVISIONING_COMPLETED — TenantProvisioningProcessor.handle() and
PlatformTenantService.createTenant() — see “Manual Approval Gate” above for why this can’t just be createdAt: a
signup that sat AWAITING_APPROVAL for days would otherwise fire the founder email almost immediately after
activation instead of a day after it) and founderWelcomeEmailSentAt (null until sent — the once-only guard; stays
null on a failed send so the next day’s sweep retries it, only set on actual success).
FounderWelcomeEmailScheduler.sendDueFounderWelcomeEmails() — @Cron('0 9 * * *'), daily (a day-granularity
threshold gets a daily check, not hourly — same reasoning AssignmentReminderScheduler/PledgeReminderScheduler
already establish for their own EVERY_DAY_AT_8AM crons). Deliberately not routed through
forEachActiveTenant() — that helper scans every active tenant on every run, which would mean re-checking every
tenant on the platform daily just to find the handful newly due; instead queries tenants directly for
onboardingStatus = ACTIVE AND isActive = true AND founderWelcomeEmailSentAt IS NULL AND activatedAt <= now() - 24h,
then enters each matching tenant’s schema one at a time via runInTenantContext() (the same primitive
forEachActiveTenant() itself is built on) to read that tenant’s earliest-created active Admin+Member — a raw
CLS-scoped tx.findOne(Admin, ...) read, not an injected Admin repository, mirroring
PlatformTenantService.impersonateTenant()'s identical pattern and for the identical reason (see
tenant-typeorm.module.ts’s comment on why a plain @InjectRepository() can never see a per-job tenant
transaction). The email itself is sent outside that tenant context (UtilityService.sendEmailWithTemplate,
template founder-welcome) so branding resolves to Discuva’s own identity via the same no-tenant-in-CLS fallback
platform-admin-welcome.html/tenant-approval-needed.html already rely on — this is Jeremiah writing as Discuva’s
founder, not a tenant-branded transactional email. A tenant with no admin found is skipped (logged, not marked
sent — shouldn’t happen for a genuinely ACTIVE tenant, but defensive); a per-tenant send failure is caught, logged,
and the loop continues to the next tenant, matching every other scheduler’s resilience convention in this codebase.
No reply-to. The template deliberately doesn’t invite a reply — there’s no replyTo mechanism anywhere in the
email pipeline (SendMailOptions has no such field, across all 5 providers), so promising one would be hollow.
Points instead to the tawk.to chat widget already live in discuva-admin’s dashboard (“the chat bubble in the corner
of your dashboard”) as the real, working support channel.
Role Elevation
The access token’s role is re-validated from the live database on every request via validateAccessToken. This means if
a member is promoted to WORKER, their existing token will reflect the new role on the next request after the DB is
updated.
Department Capabilities
Certain modules are gated by a department capability rather than a specific department name or a single free-form
key. This is a full replacement of the earlier Department.key-based system: key (a single free-form string,
validated against nothing but a preset-suggestion enum) has been removed entirely, in favor of capabilities — a
fixed, code-defined, multi-value list. The old system conflated “this department’s organizational label” with “what
it unlocks,” forcing an admin to type a magic string that had to exactly match hardcoded values scattered across both
the backend and discuva-member mobile, and could only ever grant one capability per department. Capabilities decouple these:
a department can be named anything, and separately be given any combination of capabilities via checkboxes in the
admin UI.
How it works:
- Each
Departmenthas acapabilities: DepartmentCapability[]column (text[], default{}).DepartmentCapability(src/department/enums/department-capability.enum.ts) is a fixed enum — six values today, each named after the action it unlocks rather than a department:MANAGE_SUNDAY_SCHOOL,MANAGE_CHILDREN_CHURCH,MANAGE_PRAYER_REQUESTS,MANAGE_EVANGELISM_CONVERTS,MANAGE_FOLLOW_UP,FRONT_DESK_OPERATIONS. A capability only belongs in this list if a real feature is gated on it — mirrors theKNOWN_MODULESpattern.CreateDepartmentDto/UpdateDepartmentDtovalidatecapabilitieswith@IsEnum(DepartmentCapability, { each: true }); unlike the oldkey, admins pick from a fixed checkbox list, not free text, and a single department can hold more than one capability at once. - A
WorkerProfilehas a primarydepartmentand an optionalsecondaryDepartment. A worker has a capability if either department’scapabilitiesarray includes it. DepartmentAccessService(src/department/service/department-access.service.ts, exported fromDepartmentModule) is the single shared implementation of this check —hasCapability(memberId, capability)(boolean, for composing with other conditions like “or is a pastor” or “or is the class teacher”) andassertHasCapability(memberId, capability, message?)(throwsForbiddenException). Used by 7 services —attendance,evangelism,sunday-school,prayer-request,service-session,children-church, andfollow-up— each calling it with its own capability and message; two of those services (evangelism,follow-up) also have an inline duplicate of the same check where they already have theWorkerProfileloaded and calling the service would mean a redundant query.GET /auth/mecomputes a flatcapabilities: DepartmentCapability[]field onMemberDto(@Transform, same pattern asclergy) — a deduped union of the primary and secondary department’s capabilities. discuva-member mobile checks this one field (profile?.capabilities?.includes("X")) instead of independently re-deriving the primary-or-secondary union at every call site.- HOD (head-of-department) assignment is always restricted to the worker’s primary department (unrelated to capabilities).
Sunday School access — a request passes if any of the following is true:
- Caller is a WORKER whose primary or secondary department has the
MANAGE_SUNDAY_SCHOOLcapability. - Caller is the appointed teacher of the specific Sunday School class being acted upon.
Admin-only SS routes (delete class/session) use AdminGuard + SUNDAY_SCHOOL_WRITE instead.
Children Church access — a request passes if any of the following is true:
- Caller is a WORKER whose primary or secondary department has the
MANAGE_CHILDREN_CHURCHcapability.
Admin-only CC routes (age group/class group CRUD, slot-level check-in report) use
AdminGuard + CHILDREN_CHURCH_WRITE/READ instead.
Migration note: 1790208000000-ReplaceDepartmentKeyWithCapabilities.ts backfills the 6 legacy key values that
had real behavior behind them (ADMIN, EVANGELISM, SUNDAY_SCHOOL, PRAYER, CHILDREN_CHURCH, FOLLOW_UP) into
their corresponding capability, then drops the key column. Any department whose key was one of the other preset
values (WORSHIP, USHERING, MEDIA, PROTOCOL, WELFARE, YOUTH, YOUNG_ADULTS) or a custom string had no real
behavior behind it and is simply dropped — those departments end up with capabilities: [].
5. Module Reference
Multi-Tenant Request Scoping
Every request except /v1/platform/*, /v1/signup, and the version-neutral /, docs, health routes goes
through TenantMiddleware: it resolves the tenant from the Host header’s subdomain (stripped of
APP_BASE_DOMAIN, e.g. church-alpha.example.com → church-alpha; localhost in dev, so *.localhost works with
no /etc/hosts changes), then wraps the entire rest of the request — guards, interceptors, and the handler — in one
DB transaction with SET LOCAL search_path set to that tenant’s schema. Full design in
docs/MULTI_TENANT_MIGRATION.md §4.3/§4.4.
Fallback resolution for a fixed, non-wildcard host (added 2026-08, extended to discuva-member 2026-08):
discuva-admin is deployed at a single admin.discuva.org origin shared by every tenant, not a per-tenant wildcard —
admin is in RESERVED_SUBDOMAINS (src/tenant/utility/extract-subdomain.ts) specifically so no tenant could
ever collide with it, but that also means extractSubdomain() always returns null there: there is no subdomain
in the Host header to strip. discuva-member has a real per-tenant wildcard for its own hosting
({tenant}.discuva.org, resolved the normal Host-header way, unchanged) but its API calls target the separate,
dedicated api.discuva.org host every other app calls directly — same problem, different reason: the Host header
TenantMiddleware sees on that call carries no subdomain either. When that happens, TenantMiddleware tries two
fallbacks, in order, before giving up with the same 404 Tenant not found as before:
- A verified JWT tenant claim. Every access/refresh token, both surfaces, embeds
tenantId/schemaNamein the payload at sign time (AuthService.generateTokens(), readingcls.get('tenantId')/cls.get('schemaName')— already correctly set for that request by whichever mechanism resolved it, Host header or this same fallback on the login request itself).TenantMiddlewarechecks theAuthorization: Bearerheader first — trying the access secret, thenREFRESH_JWT_SECRET, since a Bearer header can legitimately carry either token type (discuva-admin’s refresh flow sends its refresh token via an httpOnly cookie; discuva-member’s sends it via this same header instead, a pre-existingRefreshJwtStrategydesign, not something added for this) — then therefresh_tokenhttpOnly cookie (verified withREFRESH_JWT_SECRET) as a second fallback. Covers every authenticated request, including a bare/v1/auth/refreshcall from either app. This is genuinely safe against spoofing: the claim only exists inside a JWT whose signature already proves it came from this server, at a moment CLS already held the correct tenant — there’s no way for a client to write an arbitrarytenantIdinto a token it can’t forge the signature for. X-Tenant-Subdomainheader. discuva-admin sends it on every pre-auth request where no token exists yet:POST /v1/auth/admin-login(needs to know which tenant’sMember/Admintables to check credentials against before it can issue anything), andPOST /v1/auth/forgot-password/reset-password(same reasoning —PasswordResetOtpis tenant-schema-scoped too, and both the “Forgot password” flow on the login screen and the first-time/set-passwordflow reached from a welcome email are equally pre-auth). discuva-member sends it on every request toapi.discuva.org(derived from its own Host header viagetCurrentTenantSubdomain(),utils/tenant/api-base-url.ts), not just pre-auth ones — harmless to include always, and it’s whatapp/manifest.ts’s server-side, pre-authGET /tenant/infocall for PWA branding relies on, since no JWT exists there yet either. This header is not cryptographically trusted the way the JWT claim is — it’s exactly as trustworthy as a user typing a workspace URL: a wrong or malicious value just resolves to the wrong (or a nonexistent) tenant’s schema, where the supplied email/OTP/session simply won’t match any real row, so it can never grant access to anything, only ever fail against the wrong place. On an authenticated discuva-member request it’s redundant with (and always loses to) the JWT claim above — sent anyway for the handful of pre-auth calls that need it, and it’s simpler to attach unconditionally than to special-case which requests do.
Both fallbacks are skipped entirely — not even attempted — whenever the Host header itself already resolved a subdomain, so discuva-member’s own hosting (as opposed to its outgoing API calls) is completely unaffected; this exists purely to make a fixed, non-wildcard destination host work for the two kinds of traffic that need one.
Fixed bug: the refresh_token cookie’s Path silently broke fallback #1 for discuva-admin on every route except
the refresh endpoint itself. The cookie used to be scoped to path: '/v1/auth/refresh' (AuthController’s
REFRESH_COOKIE_PATH) — meaning the browser only ever attached it to that one route, even though
TenantMiddleware’s fallback reads that same cookie on every route. In practice: discuva-admin’s access token
lives only in an in-memory JS variable, so once it expired (a backgrounded tab, mobile tab suspension) a normal
request’s Authorization header failed verification, the refresh cookie wasn’t sent (wrong path) so the fallback
found nothing, and the middleware threw a 404 “Tenant not found” — before any guard ran, so the existing
401-triggered refresh interceptor in discuva-admin’s axios client never saw it and never re-authenticated. The
session stayed stuck until a hard reload forced a direct call to /v1/auth/refresh, the one path where the cookie
was actually valid. Fixed by widening REFRESH_COOKIE_PATH to /v1 (covers the whole API, still excludes
anything outside it) so the fallback works on every route; an expired access token now correctly falls through to
an ordinary 401 from the auth guard, which the pre-existing reactive refresh flow already handles.
Distinct error responses per tenant state, not a single generic 404: the tenant lookup is findOneBy({ subdomain }), deliberately not filtered by isActive, so a row that exists but isn’t (yet, or anymore) usable gets
a response that actually explains why, using onboardingStatus (see “Async Tenant Provisioning + Onboarding State
Machine” above) to disambiguate:
- No
Tenantrow at all for the subdomain →404 Tenant not found(unchanged). onboardingStatusisPENDING/PROVISIONING→503, “This workspace is still being set up. Please check back in a moment.” — this is the common case for a subdomain hit moments after signup, before the queue has finished.onboardingStatusisFAILED→503, “There was a problem setting up this workspace. Please contact support.” — deliberately no technical detail in the public response;GET /platform/tenants/:id/onboarding-eventsis where that lives.onboardingStatusisACTIVEbutisActiveisfalse→403, “This account has been suspended. Please contact support.”onboardingStatusnever reverts onceACTIVE, so this combination only ever meansPlatformTenantService.suspendTenantwas used — a materially different situation from “still provisioning” that a flatisActivecheck couldn’t previously tell apart (both 404’d identically before this).onboardingStatus === ACTIVE && isActive === true→ proceeds normally, as before.
For any new module with tenant-owned tables: register entities with TenantTypeOrmModule.forFeature([...])
(src/tenant/utility/tenant-typeorm.module.ts), not TypeOrmModule.forFeature([...]). Plain TypeOrmModule
repositories never see the tenant transaction regardless of request scoping — this is a @nestjs-cls/transactional
limitation, not a bug to work around per-call. TenantTypeOrmModule is a drop-in replacement using the same DI
token, so @InjectRepository(Entity) call sites in services need no changes. Only genuinely global, public-only
tables (Tenant, PlatformAdmin, Plan/Subscription, and similar control-plane entities) should keep plain
TypeOrmModule.forFeature().
Letting the database scale to zero (SchedulerGateService, added 2026-10-01): the frequent jobs — programme
auto-start (5 min), absence marking (5 min), rental status (10 min), event reminders (15 min), class session reminders
(hourly) — used to open a transaction in every church on every tick, so the Neon database never stayed idle for the
5 minutes it needs to scale to zero. They now use SchedulerGateService.forEachDueTenant(job, txHost, logger, fn)
(src/tenant/scheduler-gate/): fn returns when that church next needs the job (next auto-start time / event end /
booking start or end / reminder fire_at / class reminder threshold or session start; “now” while something due is
still waiting), stored in Redis as global:scheduler:next-due:{job}:{tenantId}. Later ticks skip that church — no
transaction, no query — until then. Safety rails: never sleeps past the top of the next hour (so a missed wake-up delays
work by at most an hour, and all jobs wake the database together); SchedulerGateSubscriber (TypeORM subscriber on the
shared DataSource) clears the marker on any insert/update/delete of the tables a job reads (JOBS_BY_ENTITY: service
programmes/sessions/slots/configs, events, event reminders, rental bookings, church classes, class sessions, church
settings), whichever code path writes; a write during a run (global:scheduler:woken:*) stops that run from sleeping;
a failed run or a Redis error means the job runs as before. The active-church list is cached in Redis for 10 minutes
(global:scheduler:active-tenants, cleared when a Tenant row changes). Simulated over days of random schedules, every
item fires at the same tick as without the gate; a quiet day goes from 288 runs per 5-minute job to 24. Daily jobs are
unchanged. The connection pool’s Fly’s 30-second /health check no longer queries the database (/health/deep does). DATABASE_POOL_MIN now defaults to 0 (idle connections close after 30s) and
connectionTimeoutMillis is 10s so the first query after the database wakes doesn’t fail.
Scheduler tenant iteration (forEachActiveTenant): @Cron()-decorated methods run with no CLS context at all —
there’s no HTTP request for TenantMiddleware to hook into. A tenant-scoped repository called from inside a
scheduler with no CLS context silently falls back to the plain public-search-path manager instead of throwing, so
without doing anything about it a scheduler processes whatever stale/orphaned rows happen to sit in public, not
any real tenant’s data. Every scheduler that touches tenant-scoped data fetches every active Tenant and re-enters
that tenant’s context once per tenant via the shared helper forEachActiveTenant(tenantRepo, cls, txHost, logger, fn) (src/tenant/utility/for-each-active-tenant.ts), which wraps the existing runInTenantContext() helper (the
same one EmailProcessor already used) in a fetch-loop-catch: fn runs once per active tenant inside that tenant’s
cls.runWith(...) + SET LOCAL search_path transaction, and one tenant throwing is caught and logged without
stopping the rest of the batch. Any distributed Redis lock a scheduler already had (e.g. lock:pledge-reminders)
still wraps the whole @Cron method across all tenants, unchanged — it guards against two app instances racing,
not tenants racing each other. A service that opens its own dataSource.transaction() inside a scheduler needs
extra care: a fresh top-level transaction doesn’t inherit the outer SET LOCAL search_path (likely a different
pooled connection) and would silently write to the wrong schema — such call sites are rewritten to use the ambient
this.txHost.tx manager instead (see AttendanceService.markAbsentees() and RecurringEntryScheduler, the latter
wrapping each recurring entry in its own Postgres SAVEPOINT so one entry’s failure doesn’t abort the whole
tenant’s batch). BranchRollupScheduler predates this helper and hand-rolls the identical fetch-loop pattern
itself; YoutubeSubscriptionScheduler and SubscriptionLapseScheduler’s top-level query are exempt because their
data is genuinely control-plane (public-schema), not tenant-owned.
CORS origin validation (createCorsOriginValidator): wildcard-subdomain tenancy means the set of valid frontend
origins is unbounded — a new tenant’s subdomain is valid to call the API the moment it’s provisioned, so a static
CORS_ORIGINS allowlist can never enumerate them all (and previously didn’t try to — it silently rejected every
tenant subdomain origin that wasn’t hand-added to the list, a real bug, not just a gap). src/main.ts’s
app.enableCors(), ServiceSessionGateway, and GameSessionGateway all now validate the incoming Origin header
dynamically via the shared createCorsOriginValidator() (src/tenant/utility/cors-origin-validator.ts): allow if
the origin’s hostname equals APP_BASE_DOMAIN or ends in .${APP_BASE_DOMAIN} — the exact same suffix logic
extractSubdomain() uses for tenant resolution, so “is this origin allowed to call the API” and “does this host
resolve to a tenant” can never disagree — with a small explicit CORS_ORIGINS allowlist checked first for origins
that don’t fit the pattern (a separate marketing site, API docs, internal ops tooling). Requests with no Origin
header (curl, server-to-server calls, mobile apps) are always allowed, matching the previous behavior. Custom
domains (a tenant’s own domain, e.g. giving.theirchurch.org) don’t end in APP_BASE_DOMAIN and are rejected by
this check today — deliberately deferred, same as tenant_domains itself (see Custom Domains note below); adding
support means a second branch in createCorsOriginValidator() checking a cached set of verified custom domains
before falling through to reject (must stay cached, not a live DB query — this runs on every request/connection).
Custom domains (deferred, not built): docs/MULTI_TENANT_MIGRATION.md earmarks a future tenant_domains table
for letting a church map their own domain (giving.theirchurch.org) to their tenant, instead of only
their-church.<APP_BASE_DOMAIN>. Not built — subdomain routing is sufficient for now. When it is: (1) resolve the
custom domain to the tenant’s canonical subdomain via a new lightweight public endpoint, cached client-side, so
discuva-member’s getCurrentTenantSubdomain() (utils/tenant/api-base-url.ts) can resolve it to the same
subdomain it already sends as X-Tenant-Subdomain today — no changes needed to CLS/SET LOCAL search_path/
tenant-scoped repos, all of that stays subdomain-keyed; (2) a domain must be
DNS-verified (TXT record token, or requiring it already point at this infrastructure) before being trusted — a row
in the table must never be sufficient on its own, since nothing stops a tenant from entering a domain they don’t
control; (3) TLS is an infrastructure decision independent of this codebase — a single wildcard cert covers every
subdomain automatically, but each custom domain needs its own certificate (e.g. a reverse proxy that automates
ACME/Let’s Encrypt on demand, or requiring the tenant sit behind a proxy like Cloudflare that terminates TLS for
them) — nothing here provisions that.
Auth Module
Routes: POST /auth/signup, POST /auth/login, POST /auth/admin-login, POST /auth/refresh,
POST /auth/logout, GET /auth/me, POST /auth/change-password, POST /auth/email-change/request,
POST /auth/email-change/confirm, POST /auth/forgot-password,
POST /auth/reset-password, POST /auth/device-reset/request, POST /auth/device-reset/verify,
POST /auth/webauthn/login/options, POST /auth/webauthn/login/verify,
POST /auth/webauthn/register/options, POST /auth/webauthn/register/verify,
GET /auth/webauthn/credentials, DELETE /auth/webauthn/credentials/:id
POST /auth/email-change/* require an authenticated session (member or worker) — see Self-Service Email Change Flow
above for the full request/confirm sequence.
Route separation: POST /auth/login is for the mobile app (members & workers) and enforces device lock —
deviceId is required. POST /auth/admin-login is for the web admin portal — it verifies that the caller has an
active Admin record and has no device check. Both routes use the same Passport LocalAuthGuard for credential
validation.
WebAuthn / biometric login (WebauthnService, src/auth/service/webauthn.service.ts) — mobile-app-only
alternative to password login using the browser’s platform authenticator (Face ID / Touch ID / Android fingerprint /
Windows Hello), built on @simplewebauthn/server. member_webauthn_credentials (tenant schema, migrated in
src/migrations/tenant/) holds one row per registered device/authenticator — deliberately no uniqueness constraint
on member_id, unlike member_sessions’ one-row-per-surface rule: a member can register several devices
independently.
- Usernameless (discoverable/resident credentials) — registration sets
residentKey: 'required', soPOST /auth/webauthn/login/optionsneeds no email and returns generic options withallowCredentialsomitted; the browser/OS itself resolves which registered credential to use and prompts biometrics directly. The resolvedmemberIdonly becomes known oncePOST /auth/webauthn/login/verifysucceeds (matched by the assertion’scredentialIdagainst the stored row), at which pointAuthService.loginWithWebauthn(memberId)issues tokens via the exact samegenerateTokens()used by password login — no separate token-issuance path exists. loginWithWebauthnruns the same active/status checksvalidateMember()applies for password login (INACTIVEstatus, revoked/suspended worker), but deliberately does not applylogin()'s single-deviceIdlock — that lock’s threat model (a shared/leaked password) doesn’t apply to a hardware-bound private key that never leaves the device, and the entire point of allowing several WebAuthn credentials per member is several trusted devices logged in independently.- RP ID is the platform’s fixed
APP_BASE_DOMAIN, never the tenant subdomain — WebAuthn allows an RP ID that’s a registrable-domain suffix of the current origin, so a credential registered onchurch-a.<base>still validates when asserted fromchurch-a.<base>later (the request’s actualOriginheader is still checked exactly viaexpectedOriginon every verify call). RP name (shown in the OS-level prompt) is resolved per-request from the current tenant’s ownTenant.namewhere available, falling back toPRODUCT_NAME— same personalization as every other tenant-branded surface in this app. - Challenges are ephemeral Redis entries (
CacheService, 5 min TTL, keywebauthn_challenge:<memberId-or-random-challengeId>) — never persisted to Postgres, single-use (deleted immediately after a verify attempt, success or failure). - Registration (
POST /auth/webauthn/register/options//verify) requires an existing authenticated session (JwtAuthGuard, same as any other/auth/*account-management route) — a member enrolls a new device from within an already-logged-in session, typically from Account settings. - Device management:
GET /auth/webauthn/credentialsreturns{ id, deviceName, createdAt, lastUsedAt }per row — nevercredentialId/publicKey, which the client has no use for.deviceNameis derived from the registering request’sUser-Agentat enrollment time (“iPhone”, “Android device”, “Mac”, “Windows PC” — a label only, never used for anything security-relevant).DELETE /auth/webauthn/credentials/:idis scoped to(id, memberId)—404if the row doesn’t belong to the caller. Both credential registration and removal are audit-logged (MEMBER_WEBAUTHN_CREDENTIAL_REGISTERED/_REMOVED), and a successful biometric login logsMEMBER_LOGIN_WEBAUTHN(distinct fromMEMBER_LOGIN, so the audit trail can tell login method apart). - Clone/replay protection: each credential’s signature
countermust strictly increase on every successful authentication (@simplewebauthn/server’sverifyAuthenticationResponseenforces this) — a same-or-lower counter fails verification, the standard signal an authenticator’s key material was cloned. - Interaction with Self-Service Device Reset: because WebAuthn logins never check
deviceId,POST /auth/device-reset/verifyalso callsWebauthnService.revokeAllCredentials(memberId)— every registered credential is deleted, not just the password-login device lock. See the Device Reset section above for why (a lost device’s biometric key would otherwise survive a reset intended to lock it out).
Member Module
Manages the universal identity. Admin portal routes (list members, promote/revoke workers, change status, reset
passwords) are now guarded by AdminGuard + the appropriate MEMBERS_READ or MEMBERS_WRITE permission.
Routes prefix: /members
Admin-created members: POST /members (AdminGuard + MEMBERS_WRITE) lets an admin create a plain MEMBER
account directly — for members without a phone/email habit, or who otherwise can’t complete self-signup. Body is
SignupDto (same DTO as POST /auth/signup). MemberService.createByAdmin shares its implementation with
signup() via a private createMemberRecord helper: same temp password generation, changedPassword: false
(forces the change-password flow on first login), and welcome-member email with the temp password/login URL. The
only difference is the audit action — MEMBER_CREATED_BY_ADMIN (with the admin as actorId) instead of
MEMBER_SIGNED_UP. Promoting the new member to a worker afterwards is a separate step — use the existing
POST /members/:id/promote.
Self-service profile edit: PATCH /members/me (JwtAuthGuard only, no admin) lets a member/worker update their
own firstname, lastname, phoneNumber, gender, birthDay, birthMonth, birthYear, maritalStatus and
church journey — dateJoinedChurch (YYYY-MM-DD), yearBornAgain, yearBaptized (YYYY; null clears),
baptizedWithHolyGhost (UpdateMyProfileDto, all fields optional). Excludes email (handled by the OTP-gated
email-change flow — see Self-Service Email Change Flow). Admins can still edit the same fields via PATCH /members/:id.
Serve interest: POST /members/me/serve-interest / DELETE /members/me/serve-interest (JwtAuthGuard) set or
clear serveInterestAt — the in-app “I’d like to serve” request that replaced signup’s workforce step. Idempotent;
a WORKER asking returns 400. Audited as MEMBER_SERVE_INTEREST_ADDED / MEMBER_SERVE_INTEREST_WITHDRAWN.
Admins find these members with GET /members?wantsToServe=true (active members only). Cleared by: the member
withdrawing, an admin dismissing it (DELETE /members/:id/serve-interest, MEMBERS_WRITE, audited as
MEMBER_SERVE_INTEREST_DISMISSED — the member can ask again), promotion (promoteToWorker / bulkPromoteToWorker, in
the same transaction as the role change), or deactivation (PATCH /members/:id/status → INACTIVE).
Clergy designation: four AdminGuard + MEMBERS_WRITE routes manage the optional Clergy relation on a member
(same permission as promote-to-worker — no separate permission was introduced):
POST /members/:id/clergy— body{ clergyTitleId: string (uuid) }— assigns the designation;409 Conflictif the member is already clergy,404ifclergyTitleIddoesn’t match aClergyTitle.PATCH /members/:id/clergy— body{ clergyTitleId: string (uuid) }— changes the title;404if the member is not clergy, or ifclergyTitleIddoesn’t match aClergyTitle.DELETE /members/:id/clergy— removes the designation;404if the member is not clergy. Returns204.PATCH /members/:id/clergy/review-access— body{ canReviewFeedback: boolean }— grants/revokes Pastor Feedback review access, independent of title (see Clergy above and Pastor Feedback Module below);404if the member is not clergy.
See ClergyTitle above for the tenant-configurable title catalog these routes reference (GET /clergy-titles to
populate a picker with the tenant’s own titles).
clergy: { title: {id, name}, canReviewFeedback: boolean } | null is surfaced on MemberDto (GET /auth/me,
GET /members/:id, GET /members, GET /members/workers), computed from the clergy relation.
Spouse link (POST /members/:id/spouse { spouseId }, DELETE /members/:id/spouse, both AdminGuard +
MEMBERS_WRITE): a symmetric “married to” link between two Member rows — the only family-relationship concept in
the system today (a ChildProfile is not a Member/FirstTimer and has its own ChildGuardian links instead, see
§Children Church Module). Modeled as a plain self-referencing FK (members.spouse_id, nullable, ON DELETE SET NULL, migration AddMemberSpouse) rather than a TypeORM self-referential OneToOne (the inverse side has no
distinct property to map to) or a join table (unnecessary for a 1:1 pair with no extra fields yet). Both rows are
always written in one transaction (MemberService.linkSpouse()/unlinkSpouse()) so the link can never end up
one-sided — member.spouse and spouse.spouse are always mirror images of each other. linkSpouse rejects (not
silently overwrites) if either side already has a spouse — the caller must unlinkSpouse() first — and rejects
linking a member to themselves. Audit-logged as MEMBER_SPOUSE_LINKED/MEMBER_SPOUSE_UNLINKED. spouse: { id, firstname, lastname, photoUrl } | null is surfaced on MemberDto (GET /auth/me, GET /members/:id) via a new
SpouseRefDto, same shallow-ref pattern as clergy. Not loaded on the paginated GET /members/GET /members/workers list routes — an extra join on every row of a frequently-paginated endpoint for a field those
views don’t render.
Admin UI lives on the Members list’s member-detail panel (app/members/page.tsx), as its own “Spouse” card next to
the “Clergy” card — link/search/unlink actions, same place clergy assignment already lives. Deliberately not on
the Member Journey / timeline page (app/members/[id]/timeline/page.tsx): that page is a history feed (first
visit, became a worker, training milestones, visit counts), and a spouse link is a static profile fact, not an
event — it was there in an earlier pass and got moved once that mismatch was pointed out.
Deliberately not self-service: only AdminGuard routes can set/clear the link, there is no member-facing
POST/DELETE equivalent. A member can see their own linked spouse (discuva-member’s account page reads spouse
off GET /auth/me) but cannot set or change it themselves — the same trust model the rest of the member-identity
surface already uses (department assignment, clergy designation, worker promotion are all admin-verified, not
self-declared). Letting a member link themselves to anyone with no confirmation from the other side would let one
member falsely claim to be married to another and see fields depending on that in the future; a mutual-consent
request flow was considered and deferred rather than built as a first pass.
Member timeline / “Member Journey” (admin UI label; renamed from “Digital Footprint” — church-facing language,
not tech jargon): GET /members/:id/timeline (AdminGuard + MEMBERS_READ) returns
{ events: MemberTimelineEvent[], serviceVisitCount: number, sundaySchoolVisitCount: number, isTraineeNow: boolean, childrenChurchDropOffs: number }.
events is a chronologically sorted
{ type, title, description, occurredAt }[] — the church-facing narrative of a member’s life in the system: first
visit → repeat visits → became a member → became a worker → started/completed training → department/clergy/status
changes. MemberTimelineService (src/member/service/member-timeline.service.ts) builds events from two sources,
not a dedicated history table:
- The first-timer pipeline (
FollowUpService.getFirstTimerByConvertedMemberId) — if the member ever passed throughFirstTimer(walk-in or a public form withcreatesFirstTimers), itscreatedAtbecomes “First Visit”, eachFirstTimerVisitbecomes a “Visited Again” entry, andconvertedAtbecomes “Became a Member”. A member with noFirstTimerrecord (created directly by an admin, bulk import, self-signup outside the visitor pipeline) instead gets a single “Joined the Church” event fromdateJoinedChurch ?? createdAt; the service normalizesdateJoinedChurchthroughnew Date(...)first because the TypeORMdatecolumn comes back as aYYYY-MM-DDstring while timelineoccurredAtmust always be an ISO timestamp string. - A curated allowlist of
AuditLogentries filtered bytargetId = memberId(MEMBER_ACTIVATED/DEACTIVATED,WORKER_PROMOTED/REINSTATED/REVOKED,WORKER_TRAINEE_DEMOTED,WORKER_TRAINEE_STATUS_CHANGED,CLERGY_ASSIGNED/TITLE_CHANGED/REMOVED). This is deliberately not every audit action for the member — noisy ones likeMEMBER_UPDATED,MEMBER_LOGIN, or the genericWORKER_PROFILE_UPDATED(fires on any profile field edit, carries no clean before/after) are excluded so the timeline reads as milestones, not a raw change log.WORKER_PROMOTED/REINSTATED/WORKER_TRAINEE_STATUS_CHANGED’smetadata.departmentIdis resolved to a name via one batchedDepartmentlookup (not per-event).WORKER_TRAINEE_STATUS_CHANGED’smetadata.isTraineepicks the title:true→ “Started Training”,false→ “Completed Training” (a trainee promoted to a full worker — distinct fromWORKER_TRAINEE_DEMOTED, which is a trainee losing worker status entirely and reverting to plainMEMBER). - Each
SundaySchoolAttendancerow counted towardsundaySchoolVisitCount(status = 'PRESENT', pre-conversion viafirst_timer_idand post-conversion viamember_id) also becomes its ownSUNDAY_SCHOOL_VISITevent, title “Attended Sunday School”,descriptionthe class name,occurredAtthe session’ssessionDate— so the count is never a bare number with no dated entries backing it; every visit it includes is individually visible inevents. - Outreach journey — if the member was an evangelism
Convert(converts.member_id = memberId, orconverts.first_timer_id= the member’s first-timer), the timeline opens with:MET_ON_OUTREACH(“Met on Outreach”, outreach title + team, at the convert’screatedAt);CONVERT_STATUS_CHANGEDfrom that convert’sCONVERT_STATUS_UPDATEDaudit rows (looked up bytargetId = convert.id;SAVED→ “Saved”,UNDERGOING_DISCIPLESHIP→ “Started Discipleship”,UNSAVEDskipped); and oneEVANGELISM_FOLLOW_UPevent (“Followed Up After Outreach”, “N contacts by …”, at the last evangelism contact before Follow-Up took over).Convert/ConvertFollowUpLogare registered read-only inMemberModule(importingEvangelismModulewould be circular). None of these count toward the visit counts.
isTraineeNow is a live status read straight off member.workerProfile?.isTrainee (not derived from events) — a
current-state badge for “is this person in training right now,” separate from the dated TRAINEE_STATUS_CHANGED
history entries. false for a non-worker.
childrenChurchDropOffs is a separate rollup, not part of serviceVisitCount/sundaySchoolVisitCount — see
§Children Church Module for what it counts and why it’s kept apart from the member’s own visit counts.
- Known gap:
DEPARTMENT_LEAD_ASSIGNED/REMOVEDare not yet included — those audit entries target the department row (not the member), soAuditLogService.findAll’stargetIdfilter can’t find them without ametadataquery capability it doesn’t have today. A worker whose promotion predates audit logging (legacy data, bulk imports) falls back toWorkerProfile.createdAtfor a “Became a Worker” event so they aren’t silently missing from the timeline.
serviceVisitCount and sundaySchoolVisitCount are two independent rollups, computed alongside events rather
than derived from them, of “how many times has the church actually seen this person” — split because regular
service attendance and Sunday School are different programs, not one combined headcount (previously a single
visitCount field silently summed both, which read as one inflated, unexplained number — e.g. a member with 1
service visit and 2 Sunday School sessions showed “3 visits” with no way to tell what made it up).
serviceVisitCount sums: the FIRST_VISIT/REPEAT_VISIT event count already in events (from the first-timer
pipeline, carried across the first-timer → member lifecycle instead of resetting to zero on conversion — the same
figure FollowUpService.getFirstTimerDetail computes for a still-unconverted first-timer, see below); and regular
Attendance rows with status IN ('PRESENT', 'LATE'), matching the status-filter convention used everywhere else
attendance is counted (AttendanceService). sundaySchoolVisitCount sums SundaySchoolAttendance rows linked via
first_timer_id (pre-conversion, queried only if a FirstTimer record exists) and via member_id
(post-conversion), both filtered to status = 'PRESENT' — queried separately because a Sunday School attendance
row is never re-linked from one FK to the other when a first-timer converts. ABSENT/EXCUSED Sunday School rows
and ON_LEAVE/ABSENT regular-attendance rows don’t count as a visit; ATTENDED_ONLINE isn’t included either,
consistent with these being physical-attendance figures. (Fixed 2026-09-23 — the two Sunday School counts
originally had no status filter at all, so ABSENT/EXCUSED rows inflated the combined visitCount; then split
into serviceVisitCount/sundaySchoolVisitCount the same day so the breakdown is visible, not just the total.)
Profile photo: POST /members/me/photo (multipart, field photo) uploads/replaces the caller’s own photo via
CloudinaryService (folder profile-pictures); DELETE /members/me/photo removes it. Both JwtAuthGuard only —
self-service, no admin permission required. DELETE /members/:id/photo (AdminGuard + MEMBERS_WRITE) lets an
admin clear another member’s photo for moderation. All three return the updated MemberDto. Audit-logged as
MEMBER_PHOTO_UPDATED / MEMBER_PHOTO_REMOVED, with metadata: { self: true|false } distinguishing a member’s
own action from an admin’s.
Member Bulk Import
Lets an admin create many members at once from a spreadsheet, via a preview-then-commit flow so validation errors
can be reviewed before anything is written. Controller: MemberImportController, all routes AdminGuard +
MEMBERS_WRITE.
Routes prefix: /members/bulk-import
| Method | Path | Description |
|---|---|---|
| GET | /members/bulk-import/template |
Streams a .xlsx template with the expected columns (see below) |
| POST | /members/bulk-import/preview |
Multipart upload, field name file, 5 MB cap (LimitedFileInterceptor). Parses and validates every row, persists a MemberImportJob + MemberImportRow[], returns { ...job, rows } |
| GET | /members/bulk-import/:jobId |
Refetch a previously-previewed job and its rows |
| POST | /members/bulk-import/:jobId/commit |
Creates a Member (+ WorkerProfile if the row’s department column was filled) for every row with zero validation errors; generates a random temp password per member and emails it via the welcome-member template; returns { createdCount, failedRows } |
Commit is batched, not per-row. commitImport resolves duplicate-email and department-name lookups for the
entire batch in 2 queries up front (not one of each per row), then inserts every still-eligible row’s Member
(+ WorkerProfile, if applicable) in a single transaction. This means a row can still independently fail
pre-validation (email taken since preview, unknown department) and land in failedRows exactly as before, but a
row that passes pre-validation and is included in the transaction is no longer isolated from the others — a genuine
DB-level failure during the bulk insert (e.g. a race-condition constraint violation) fails the whole commit rather
than just that one row, unlike the old per-row-transaction implementation. In practice this only matters for the
rare case a pre-validated row fails for a reason pre-validation couldn’t catch.
Template columns: First Name*, Last Name*, Email*, Phone Number, Gender (MALE/FEMALE), Birth Day (1-31), Birth Month (1-12), Birth Year, Marital Status (SINGLE/MARRIED/DIVORCED/WIDOWED), Year Born Again, Year Baptized, Baptized With Holy Ghost (TRUE/FALSE), Date Joined Church (YYYY-MM-DD), Department (optional — creates the member as a Worker), Profession, Year Joined Workforce.
Validation (at preview time, one pass over every row):
- Each row is validated against
SignupDto’s rules (required fields, formats). - A phone number is normalized to E.164 using the region from
CURRENCY_LOCALE(defaulten-NG); invalid numbers are flagged on preview rather than saved in local/national format. - Duplicate email within the file is flagged, pointing at the earlier row number.
- Email already existing in the DB is flagged.
- A filled
departmentcolumn is looked up case-insensitively; an unknown department name is flagged as an error (Unknown department: "...") and the row is excluded from commit. job.validRows= rows with zero errors; only those are eligible for commit.
Commit behavior: re-checks each valid row’s email uniqueness and department lookup (guards against a race between
preview and commit); on a per-row failure the row is marked FAILED with commitError set and processing continues
with the remaining rows rather than aborting the whole job. A job can only be committed once — re-committing an
already-COMMITTED job returns 400 Bad Request.
Admin Module
Manages the admin RBAC system used by the admin web portal. This module is @Global() — its providers (AdminGuard,
AdminService, AdminRoleService) are available across the entire app without explicit module imports.
AdminRole routes (/admin/roles):
GET /admin/roles—ADMIN_READ— list all rolesGET /admin/roles/:id—ADMIN_READ— get role by IDPOST /admin/roles—ADMIN_WRITE— create rolePATCH /admin/roles/:id—ADMIN_WRITE— update roleDELETE /admin/roles/:id—ADMIN_WRITE— delete role (blocked if active admins use it)
Admin user routes (/admin/users):
GET /admin/users—ADMIN_READ— list all admin usersGET /admin/users/me— any admin — own admin profile. Uses a dedicatedAdminService.getMyProfile()rather thanfindById()/theAdminGuard-preloaded admin — it’s the only placemember.spouseis loaded, so an admin’s own profile page can show their spouse. Kept offAdminGuard’s preload (which runs on every guarded request) and offfindById()(used for viewing other admins) so that extra join only happens on this one self-service call.PUT /admin/users/me/favourite-pages— any admin — saves the pages they pinned to the dashboard’s Quick Access (Admin.favouritePages), so pins follow them across devices. The “most used” pages that fill the remaining Quick Access slots are counted per browser (localStorage) and never sent to the API. The portal filters both by the admin’s permissions and enabled modules before showing them.GET /admin/users/:id—ADMIN_READ— get admin by IDPOST /admin/users—ADMIN_WRITE— grant admin access to a memberPATCH /admin/users/:id—ADMIN_WRITE— change admin role or active status; an admin cannot modify their own record (403)POST /admin/users/:id/revoke—ADMIN_WRITE— soft-revoke admin access (isActive = false)
Security notes:
- Admin user read endpoints (
GET /admin/users,GET /admin/users/me,GET /admin/users/:id) strippasswordanddeviceIdfrom the joined Member before returning — these fields are never returned to API clients. - Role-change audit entries capture the previous and new role name in addition to the changed field list.
Predefined role seed (migration): A one-time migration (SeedPredefinedAdminRoles) seeds 9 ready-to-use roles
covering the typical org structure. The migration is idempotent — it uses ON CONFLICT ("name") DO NOTHING so
re-running it on a database that already has these roles is safe.
| Role name | Typical use |
|---|---|
| Super Admin | All permissions |
| General Admin | Most read/write permissions excluding admin RBAC |
| Member Coordinator | Members read/write |
| Content Manager | Announcements write |
| Welfare & Pastoral | Notes read/write, members read |
| Children Church Coordinator | Children church read/write |
| Sunday School Coordinator | Sunday school read/write |
| Attendance Monitor | Attendance read |
| Leave Approver | Leave read/write |
Default seed: On application bootstrap, if DEFAULT_ADMIN_EMAIL is set and no admin exists with that email, the
system creates:
- A
Memberwithrole = MEMBERandchangedPassword = false - A
SuperAdminAdminRolecarrying all permissions - An
Adminrecord linking the two
Orphaned 'Super Admin' (with a space) role — cleaned up: TenantSchemaGenesis
(src/migrations/tenant/1790726400000-TenantSchemaGenesis.ts, the schema-genesis migration every new tenant still
runs) seeds a legacy 'Super Admin' role from before AdminRoleService’s 'SuperAdmin' (no space) naming
convention existed — immutable history, can’t be edited. Since it runs before seedTenantAdmin() (application
code, not a migration), every newly-provisioned tenant ended up with two full-permission roles: the orphaned,
never-assigned 'Super Admin', and the real, actively-used 'SuperAdmin' (the one later permission-grant
migrations like GrantSocialMediaPermissions target, and the one the real admin is actually assigned to).
seedTenantAdmin() now deletes the orphaned row for new tenants (safe unconditionally at that point — admins
is guaranteed empty, so nothing can reference it via admins.admin_role_id’s ON DELETE RESTRICT FK); a new
migration (1792566000000-RemoveOrphanedSuperAdminSpaceRole.ts, tenant schema) cleans up the rows already sitting
in existing tenants, guarded by the same “no admin references it” check so a genuine edge case is left untouched
rather than failing the migration.
Church Settings Module
Lets an admin turn optional feature modules on/off per-installation without a deploy — the mechanism that keeps the
platform usable by congregations that don’t run every ministry this codebase supports. Backed by ChurchSetting
(key unique, value: jsonb = { enabled: boolean, displayName?: string }), read through ChurchSettingsService
with a short-TTL cache (cacheService) so isEnabled() checks on every request don’t hit Postgres each time.
KNOWN_MODULES (src/church-settings/constants/known-modules.constant.ts) is the fixed list of togglable
modules, each with a required: boolean. required: true modules (departments, service_programme) can never be
disabled — PATCH /admin/settings/:key returns 400 if attempted. Everything else defaults to required: false:
incident_report, asset_management, evangelism, follow_up, pastor_feedback, prayer, sunday_school,
children_church, facility_rental, tithe, classes, announcements. A module with no row in the database is
treated as enabled (absent = on) — a fresh install has everything available until an admin opts out.
displayName override: an admin can rename a module’s label (e.g. “Pastor Feedback” → “Elders’ Feedback”) via
the same PATCH /admin/settings/:key body without touching enabled. ChurchSettingsService.upsert() merges
rather than overwrites — passing { enabled } alone preserves whatever displayName was previously set (a toggle
flip must never silently blank out a custom label). Both frontends fall back to the module’s default label when
displayName is unset.
Enforcement (ModuleEnabledGuard + @RequiresModule(key)): mirrors the existing AdminGuard +
@RequiresPermission idiom — @RequiresModule('evangelism') sets metadata via Reflector, and ModuleEnabledGuard
(added into the controller’s existing @UseGuards([...]) array, not a separate decorator call) reads it and calls
isEnabled(), throwing 403 if the module is off. Applied at the controller level across every optional module’s
admin and member-facing controllers, so a disabled module is fully unreachable via the API, not just hidden in the
UI.
GET /modules/state (JwtAuthGuard, any authenticated member/worker/admin) is the one shared source of truth
for “is module X on,” returning { key, enabled, displayName }[] for every known module (displayName falls back
to the module’s default label). Both discuva-admin’s sidebar and discuva-member mobile’s Explore/Ministry/Leadership
tiles read from this single endpoint (useModuleState() hook, near-identical implementation in both frontends)
rather than each frontend independently guessing module state — the same duplication risk already seen once with
discuva-admin’s hardcoded permission-group list (see Admin Module’s AdminPermissionGroups note below).
AdminPermissionGroups visibility tied to module state: each AdminPermissionGroup (see AdminPermission enum
reference) optionally carries a moduleKey. discuva-admin’s role-permission picker (app/admin-management/page.tsx)
and the read-only permission display (app/profile/page.tsx) both filter PERMISSION_GROUPS/AdminPermissionGroups
through isModuleEnabled(group.moduleKey) before rendering — an admin is never offered permissions for a feature
that’s disabled for their church. Core, non-toggleable groups (Members, Events & Venues, Departments, Attendance,
Finance, Administration, etc.) carry no moduleKey and are always shown.
Routes prefix: /admin/settings (admin CRUD), /modules/state (shared read endpoint, all authenticated roles)
Reminder Settings Module
Lets a tenant admin control the timing (and on/off state) of 8 reminder-email categories, per-installation, without a
deploy — previously every value below was either a hardcoded literal or a single global env var, invisible and
unconfigurable to anyone but whoever edits deploy config. Backed by the same ChurchSetting entity/table as the
Church Settings module above (key unique, value: jsonb), under a disjoint key namespace (`reminder:${key}`)
— reuses the proven pattern with zero new migration, but through its own service/controller
(ReminderSettingsService/ReminderSettingsController), since the value shape ({ enabled, thresholds }) and
whitelist (ReminderSettingKey) differ from the module-toggle shape and shouldn’t be forced through
ChurchSettingsService.
Not the same thing as EmailCategory: src/utility/email-provider/email-category.enum.ts gates every
category of email the system sends, at a coarser granularity than reminder settings — e.g.
EmailCategory.ASSET_ALERTS is shared by all 4 asset schedulers (maintenance, warranty, vehicle-expiry,
overdue-checkout), EmailCategory.FINANCE_ALERTS by both pledge and budget alerts. It used to be global-only
(env-flag booleans in EmailQueueService.isCategoryEnabled) — it now also has a per-tenant override
(EmailCategorySettingsService, see “Email Category Settings Module” below), but ReminderSettingKey remains a
separate, finer-grained, per-tenant enum layered on top of both — if either the env flag or the tenant’s
EmailCategory setting is off, that still suppresses sends regardless of any tenant-level ReminderSettingKey
setting (EmailQueueService.queueEmail checks its own two gates before a job is ever enqueued, upstream of anything
the reminder schedulers decide).
KNOWN_REMINDER_SETTINGS (src/reminder-settings/constant/known-reminder-settings.constant.ts) — the 6 keys,
each { label, unit, defaultThresholds }. thresholds is a list of signed integers whose meaning depends on the
key’s unit: for the date-based ones it’s day-offsets relative to a due/expiry date (positive = before, 0 = on
the day, negative = after/overdue); for budget_alert it’s percent-of-budget-used thresholds. Defaults exactly
match each scheduler’s prior hardcoded/env-default value, so shipping this was behavior-neutral until a tenant
actually changes one:
ReminderSettingKey |
Unit | Default thresholds | Scheduler |
|---|---|---|---|
pledge_reminder |
days relative to due date | [7, 0, -3] |
PledgeReminderScheduler |
budget_alert |
% of budget used | [80, 100] |
BudgetAlertScheduler |
follow_up_stale |
days since last activity | [7] |
FollowUpScheduler.notifyInactiveTasks |
asset_maintenance |
days before due | [7, 3, 1, 0] |
MaintenanceReminderScheduler |
asset_warranty |
days before expiry | [30, 14, 7, 1] |
WarrantyAlertScheduler |
vehicle_expiry |
days before expiry | [30, 14, 7, 1] |
VehicleExpiryAlertScheduler |
assignment_due |
days relative to due date | [3, 1, 0] |
AssignmentReminderScheduler |
class_session |
hours before session | [24, 1] |
ClassSessionReminderScheduler |
smsEnabled — email-always, SMS-optional (Training Classes only): assignment_due and class_session are the
only two reminder keys with a tenant-configurable smsEnabled: boolean (default false) on top of the usual
enabled/thresholds shape (ReminderSettingValue/ReminderSettingResponseDto/UpdateReminderSettingDto all
carry it; stored on the same flexible ChurchSetting.value jsonb column, no migration needed). Every other
reminder key is email-only and unaffected. The email always sends when a threshold matches (to member.email or
guest.email — both exist for every Training Classes enrollee now, guest or member); SMS is an additional send,
skipped entirely unless smsEnabled is on and a phone number is on file for that specific enrollee (a guest’s
phone is optional). This mirrors the guest contact model chosen for Classes generally: email-first, phone/SMS
opt-in — see the Guest entity section above.
AssignmentReminderScheduler(src/classes/scheduler/assignment-reminder.scheduler.ts): for each publishedAssignmentwith adueDate, findsIN_PROGRESSenrollees of its class (member or guest) with no matching submission yet (ClassEnrollmentLEFT JOINAssignmentSubmissionon eithermember_idorclass_enrollment_id,WHERE submission IS NULL), computesdiffDaysvs. today, and — if it matches a configured threshold — emails (assignment-due-remindertemplate,EmailCategory.ASSIGNMENT_REMINDER) and optionally SMS-nudges (generic, non-personalized text, no link) every qualifying enrollee. Cache-deduped per(assignmentId, enrolleeId, diffDays)so a reminder never double-sends within the same day.ClassSessionReminderScheduler(src/classes/scheduler/class-session-reminder.scheduler.ts): same structural pattern, keyed perChurchClasswithnextSessionAtset (not per-assignment) —diffHours, notdiffDays, matching this key’s hours-based unit. EmailsIN_PROGRESSenrollees (class-session-remindertemplate,EmailCategory.CLASS_SESSION_REMINDER) with the class name, session time, andmeetingLink(conditionally rendered in the template if set); SMS text includes the meeting link when present. Separate scheduler/key fromassignment_duesince they’re conceptually different triggers (per-assignment vs. per-class) and admins may want different thresholds for each (e.g. “1 hour before” for a meeting vs. “3 days before” for an assignment deadline).- Calendar invite: every send also attaches a generated
.icsfile (class-session.ics, built viabuildIcsEvent— see “Calendar invites (.ics)” below) so the session can be added to the recipient’s calendar directly from the email, the same treatmentservice-slot-assigned/service-slot-reminderalready give a service assignment.ChurchClasshas no explicit session-duration field, so the invite defaults to a 1-hour block starting atnextSessionAt. The invite’sUIDis keyed on`${churchClass.id}-${nextSessionAt.getTime()}@classes-session`— built once per (class, session) and reused for every recipient of that run — so repeated reminders (24h, 1h) for the same unchanged session update the one calendar entry the recipient already has, while reschedulingnextSessionAtproduces a new entry rather than silently mutating the old one.meetingLink, when set, is used as both the invite’sLOCATIONand its description text.
- Calendar invite: every send also attaches a generated
Both use the same forEachActiveTenant + Redis-lock + per-tenant getConfig() pattern as every other reminder
scheduler (see “Runtime read” below) and are registered in ClassesModule (not a separate module) — they’re
Training Classes-specific, not general-purpose. AssignmentReminderScheduler runs @Cron(EVERY_DAY_AT_8AM),
matching the date-based thresholds; ClassSessionReminderScheduler runs @Cron(EVERY_HOUR), matching its
hours-based thresholds — an hourly-granularity trigger needs an hourly check to land on the right hour.
Explicitly excluded from tenant control (unreachable by any tenant-facing route, unchanged hardcoded/global
behavior): overdue-checkout alerts (asset accountability — a deliberate product decision, not a tenant
preference), prayer reminders (dual 2-day-ahead/day-of logic doesn’t fit the list-of-offsets shape), and Pastor
Feedback’s weekly reminder (its timing is the @Cron('0 9 * * 1') schedule itself — tenant-configurable cron
cadence would need dynamic SchedulerRegistry registration, a materially different change than a settings value).
Runtime read (getConfig(key)): each of the 8 schedulers calls this inside its forEachActiveTenant(...)
callback — not once at construction — since cron jobs have no ambient tenant context outside that loop, and
CacheService’s tenant-scoped cache keys rely on the CLS store forEachActiveTenant populates per iteration. If
enabled is false, the scheduler returns before any email is queued for that tenant that run.
Per-scheduler notes:
BudgetAlertScheduler: unlike the date-based schedulers’thresholds.includes(diffDays)check, budget alerts usethresholds.some(t => utilizationPct >= t && !alreadySent(t)), sorted descending so only the highest newly-crossed threshold fires per run (matches the original 80/100 behavior of never double-alerting in one pass). Dedup moved from the old fixedalert80SentAt/alert100SentAtcolumns to a genericBudget.alertsSent: number[]jsonb column (arbitrary threshold count needs a matching data structure) — seeAddBudgetAlertsSentColumnmigration. The old columns are left in place, unused, rather than dropped, to avoid irreversible data loss on this pre-existing table.MaintenanceReminderScheduler: same generic-column treatment —MaintenanceSchedule.notifiedThresholds: number[]replaces the 4 fixednotifiedNDaysAtcolumns. The overdue branch (daysUntilDue < 0) is untouched — still an unconditional daily nag vialastOverdueNotifiedAt, not part of the configurable threshold list (deliberate:asset_maintenance’s unit is “days before due,” it was never meant to cover overdue).WarrantyAlertScheduler/VehicleExpiryAlertScheduler: same treatment onAsset—warrantyNotifiedThresholds,insuranceNotifiedThresholds,roadworthinessNotifiedThresholds(3 new jsonb columns) replace 12 old fixed columns combined. SeeAddAssetExpiryNotifiedThresholdsmigration.FollowUpScheduler.notifyInactiveTasks:FOLLOW_UP_STALE_DAYSenv var removed entirely (superseded); if multiple thresholds are ever configured, the minimum is used as the staleness cutoff (a single scalar concept, using the same list shape as the others for UI/DTO consistency, not because multiple values are meaningful here).PledgeReminderScheduler:getNextDueDate’s recurrence-search window, previously hardcoded to look 8 days ahead (matched the old fixed7threshold), now derives its lookahead fromMath.max(...thresholds)— a tenant configuring a threshold further out than 7 days would otherwise silently never match, since the search would stop before reaching it.
Routes: GET/PATCH /admin/reminder-settings, GET/PATCH /admin/reminder-settings/:key — same AdminGuard +
AdminPermission.ADMIN_WRITE-on-write pattern as /admin/settings above.
Frontend: discuva-admin’s /notification-settings page (own layout.tsx wrapping <Shell> — every new
top-level route needs one, there is no global Shell in root layout.tsx) — one row per setting: an enabled/disabled
toggle plus an editable numeric-chip list (add/remove) for thresholds. The assignment_due/class_session rows
additionally show an SMS toggle bound to smsEnabled — every other row hides it, since only those two keys carry
the field.
Email Category Settings Module
Lets a tenant admin turn off any of the 15 EmailCategory values for their own church — the gap that made every
category effectively mandatory in practice: the only pre-existing suppression mechanism
(EmailQueueService.isCategoryEnabled) was gated behind process-wide EMAIL_<CATEGORY>_ENABLED env vars, so
disabling one meant disabling it for every tenant simultaneously (a single NestJS process serves all tenants).
Same ChurchSetting-backed pattern as Reminder Settings above (own key namespace, `email_category:${category}`,
zero new migration), own service/controller (EmailCategorySettingsService/EmailCategorySettingsController) since
the value shape ({ enabled }) and whitelist (EmailCategory, already defined in src/utility/email-provider/) are
unrelated to the module-toggle and reminder-threshold shapes.
Two independent gates, either can suppress: EmailQueueService.isCategoryEnabled(category) checks the env var
first (unchanged, still the platform-wide kill switch — rarely touched, requires a redeploy) and only calls
EmailCategorySettingsService.isEnabled(category) if the env var didn’t already suppress it, so a globally-disabled
category never even reaches the tenant-level DB/cache lookup.
Module wiring note: EmailCategorySettingsModule is @Global() but deliberately does not import
UtilityModule (also @Global()) — EmailQueueService lives inside UtilityModule and needs to inject
EmailCategorySettingsService, so an explicit cross-import would be circular. Since both modules are global, this
isn’t needed: UtilityModule’s own exports (CacheService, AuditLogService) resolve into
EmailCategorySettingsService’s constructor regardless of whether its module lists UtilityModule in imports.
KNOWN_EMAIL_CATEGORIES (src/email-category-settings/constant/known-email-categories.constant.ts) — a
{ label, description } per category, all defaulting to enabled (no DB row = on, same fail-open default every
other settings mechanism in this codebase uses).
Fixed alongside: EmailCategory.SERVICE_PROGRAMME_ASSIGNMENT was referenced in
EmailQueueService’s flag map but had no corresponding EMAIL_SERVICE_PROGRAMME_ASSIGNMENT_ENABLED entry in
env.validation.ts/.env.example — harmless while true (undefined !== false), but meant the var could never
actually be set without Joi’s forbidNonWhitelisted rejecting it. Now registered like the other 14.
Routes: GET/PATCH /admin/email-category-settings, GET/PATCH /admin/email-category-settings/:category — same
AdminGuard + AdminPermission.ADMIN_WRITE-on-write pattern as /admin/reminder-settings.
Separate Email and Push switches: the stored value is { enabled, pushEnabled } — enabled gates email,
pushEnabled gates push. PATCH accepts either or both (at least one). Responses add hasPush (whether any push in
PUSH_CATALOGUE belongs to the category) and pushEnabled (false when hasPush is false). Rows saved before the
split have no pushEnabled; it falls back to enabled, so a church that had switched a whole category off keeps its
push off. isPushEnabled(category) is cached under push-category-settings:{category} and cleared on update.
Delivery mode: the value also carries pushFirst (default false), and responses add pushFirst plus a derived
mode: EMAIL_AND_PUSH (both on), PUSH_FIRST (both on + pushFirst), PUSH_ONLY, EMAIL_ONLY, OFF. PATCH
accepts { mode } (what the admin UI sends — it is expanded into the three flags) or the raw flags as before; a push
mode for a category with no push notifications is a 400. isPushFirst(category) (cached under
push-first-category-settings:{category}) is true only while email and push are both on. Push first is applied in
NotificationDispatchService.notifyMember: when the same call carries an email and a push, an email address is
dropped if its member (email.recipientMemberId, or email.recipientMemberIds parallel to a multi-address to) is in
the push’s memberIds and has a push subscription (PushNotificationService.membersWithSubscription, one query on
push_subscriptions, unique per member). Emails with no paired push are never suppressed. Flows that pass recipient
ids and so honour Push first: service-programme assignments and reminders (individual and department), Sunday School
Q&A, and event reminders; everything else sends email as before.
Frontend: the “Email Categories” section on discuva-admin’s /notification-settings page has one row per category
with a delivery-mode select (Email + Push / Push first / Push only / Email only / Off; Email / Off where there is no
push) and a one-line explanation for the less obvious modes.
Push catalogue (src/notification-catalogue/push-catalogue.ts): PUSH_CATALOGUE holds every push notification’s
default wording, keyed by PushNotificationKey — category, admin-facing label/description, title/body with
{{placeholders}}, default url and a sample value per placeholder. Senders pass
{ key, vars, idempotencyKey, url? } to PushNotificationService.dispatchToMemberIds/dispatchToWorkerProfileIds or
NotificationDispatchService.notifyMember({ push }). PushNotificationService checks the category’s Push switch,
fills placeholders with plain token replacement (never a template engine, so future church-edited wording can’t run
code) and clips to 60/150 characters. Announcements are the one exception: an admin writes them, so they pass
{ title, body, url, idempotencyKey } and aren’t gated by a category switch. Before this, prayer, pastor-feedback and
announcement pushes ignored the category switches entirely.
Custom push wording (notification customization, Phase 1): churches on a plan with
PlanFeature.NOTIFICATION_CUSTOMIZATION (notification_customization, added to every Pro variant by root migration
AddNotificationCustomizationToProPlans) can replace a catalogue push’s title and/or message.
- Storage: tenant table
notification_template_overrides(NotificationTemplateOverride, tenant migrationCreateNotificationTemplateOverrides) —channel(PUSH;EMAILreserved for Phase 2),template_key, nullabletitle/body(null = use the default for that field),updated_by_id(members,SET NULL); unique(channel, template_key). - Sending:
PushNotificationServiceasksNotificationTemplateService.resolvePushTemplate(key)for the wording. It returns the church’s override only while the church’s plan includes the feature, so a downgraded church falls back to the defaults without losing its saved text. Overrides are cached per church undernotification-overrides:push(5 min), cleared on every save or reset. - Validation on save: text is made plain (HTML tags and line breaks removed), can’t be empty, is limited to 60
(title) / 150 (message) characters, and may only use that notification’s own placeholders — anything else is
rejected with the list of valid ones. Saving wording identical to the default stores nothing. Audited as
NOTIFICATION_TEMPLATE_UPDATED/NOTIFICATION_TEMPLATE_RESET. - Endpoints (
admin/notification-templates,AdminGuard; there is no separate church-settings permission, so the sameadmin:read/admin:writepair as Notification Settings):GET push(every plan — returns{ customizationAvailable, items }so the portal can show defaults with an upgrade prompt), and on plans with the feature:PUT push/:key{ title, body },DELETE push/:key(reset to default),POST push/:key/test({ title?, body? }draft; sends it filled with sample values to the calling admin’s own device, or returns{ sent: false, reason: 'NO_DEVICE' }if they haven’t turned on notifications). NOTIFICATION_CUSTOMIZATIONis labelled “Notification Customization” inPlatformCapabilityServiceso the platform Plans page lists it.
Custom email wording (notification customization, Phase 2): same plan feature and permissions as push. Eight
emails are customizable so far — welcome-member, happy-birthday, service-reminder,
first-timer-membership-invite, tithe-proof-confirmed, pledge-contribution-confirmed, class-session-reminder,
assignment-due-reminder.
- Catalogue:
EMAIL_CATALOGUE(src/notification-catalogue/email-catalogue.ts), keyed by the existing template name, holds each email’s default wording (reproducing the old files word for word, except the service reminder now says “Open the app” rather than naming the product), placeholders with sample values, atoVars(data)mapping from the sender’s template data to friendly placeholder names, alockedNotefor admins, andsampleDatafor previews.categoryisnullfor the welcome email (it always sends). - Layout: these emails no longer have standalone HTML files. They render as
templates/layouts/base.html(shared head, styles, logo header, sign-off and footer) wrappingtemplates/content/<key>.html, which holds only the optional heading, the editablemessage, the locked block (credentials, buttons, amounts, dates, links) and the editableclosing. - Editable fields:
subject,heading,message(rich),closing(rich),signoff,signature. Plain fields are stripped to text; rich fields are cleaned withSanitizationService.sanitizeForEmail(passed in from the controller so the template service, loaded byEmailQueueService, doesn’t pull in jsdom). Every field may only use the email’s own placeholders plus{{church_name}}; subject and message are required. Only fields that differ from the default are stored, innotification_template_overrides.content(jsonb, tenant migrationAddEmailContentToNotificationTemplateOverrides), cached per church undernotification-overrides:email. - Sending:
EmailQueueService.queueEmailWithTemplate*detects catalogue template names and renders them withNotificationTemplateService.resolveEmailWording()(church edits while the plan allows, else defaults) — including the subject, which replaces the caller’s. Placeholders are filled by plain token replacement with HTML-escaped values; church wording is inserted as data and never compiled by Handlebars. Every other email still loads its own file unchanged. - Class reminders now also pass
statusTitle(e.g. “Starts in 1 Hour”) so the default subjects stay identical.
All emails customizable, plus change history (notification customization, Phase 3):
- Every member/worker email is now in the catalogue (63 in total). The 55 added in this phase live in
EXTRA_EMAIL_CATALOGUE(src/notification-catalogue/email-catalogue.extra.ts), merged intoEMAIL_CATALOGUE, which is now keyed by plain template name (EmailTemplateKeystill names the original eight). Their old standalone files were split intotemplates/content/<key>.htmlplus, where the email had its own styles (OTP boxes, asset tables, banners),templates/content/<key>.css, which the layout injects into<head>via{{{ extra_styles }}}. The visible body text of every converted email matches the old file; the differences are the standard footer (the “Need help?” line now appears wherever a support email is set), three simplified hidden preview lines (asset maintenance, programme assignments, slot assigned), the child-pickup email now using the standard layout, and the product name dropped from three defaults (“your account” / “the app” in device-reset confirmation, login security alert and online attendance request). - Still fixed (not in the catalogue): platform emails (
founder-welcome,platform-admin-welcome,tenant-approval-needed,tenant-welcome) and three admin-only reports with bespoke layouts (finance-budget-alert,report-export,service-session-report). - Subjects: the added emails default to
{{default_subject}}, which is filled with the subject the sending code passes (so dynamic subjects keep working); churches may keep it, surround it with their own words, or replace it. If a subject renders empty, the sender’s subject (or the email’s label, in previews) is used. - Optional parts: the sign-off paragraph is hidden when both
signoffandsignatureare empty.messageis required only when the default has one — some emails’ default message is empty because all of their text is in the locked block. - List grouping: emails without a category show under their catalogue
group(Classes,Finance requests,Giving,Workforce) or “Account emails”. - Recipient details in every message: besides each message’s own placeholders (and
{{church_name}}), every email and push accepts{{first_name}},{{last_name}},{{full_name}},{{email}},{{phone}},{{title}}(Mr; Mrs if married/widowed; Miss if single; Ms otherwise; blank without a gender),{{church_title}}(Brother/Sister) and{{department}}(a worker’s primary department) —RECIPIENT_PLACEHOLDERSinsrc/notification-catalogue/recipient.ts. Values the sender passes win; recipient details only fill gaps.NotificationRecipientServicelooks the member up (by the singletoaddress for email, case-insensitive; by member id for push, one query per dispatch) only when the wording uses a recipient token the sender didn’t supply, so default wording costs no extra query. A multi-address email, a non-member address or a failed lookup leaves those tokens blank rather than failing the send. Pushes whose wording uses them are rendered per member. Previews use sample values. Test sends (POST push|email/:key/test) use the calling admin’s own linked-member details for these placeholders — blank where unknown, as a real send would be, falling back to samples only if the member can’t be found — and, in emails, for the name shown in locked parts; message-specific values (amounts, dates, class names) stay samples. The follow-up task email’s first-timer contact placeholders are{{first_timer_email}}/{{first_timer_phone}}so{{email}}/{{phone}}always mean the recipient. - Reads on the send path are schema-qualified: most senders queue emails/pushes fire-and-forget, so the work
often runs after the request’s (or scheduler’s) tenant transaction has closed, when a tenant repository silently
falls back to the
publicschema. Wording overrides, recipient details, the per-category Email/Push switches (church_settings) and push subscriptions/worker lookups are therefore read withqueryTenant()(src/tenant/utility/query-tenant.ts), which prefixes the CLSschemaName(validated) and returns no rows when there is no church context. Wording lookups also fall back to the defaults on any error, so a customization problem can never stop an email or push from sending. (First seen in production asrelation "notification_template_overrides" does not existonPOST /tithes/me/statement/send.) - Change history: tenant table
notification_template_versions(NotificationTemplateVersion, tenant migrationCreateNotificationTemplateVersions) —channel,template_key,action(SAVED/RESET/RESTORED),content(jsonb snapshot of the full wording in effect after the change:{ title, body }for push, all six email fields for email),created_by_id(members,SET NULL); indexed on(channel, template_key, created_at). A row is written on every save, reset and restore; only the newest 20 per message are kept. Restoring re-saves the snapshot through the normal save path, so it is re-validated (a version using a placeholder that no longer exists is refused) and is itself recorded asRESTORED. - Endpoints:
GET push/:key/historyandGET email/:key/history(admin:read, any plan) return[{ id, action, content, changedBy, createdAt }], newest first;POST push/:key/history/:versionId/restoreandPOST email/:key/history/:versionId/restore(admin:write+ plan feature) return the updated template view.
Event Module
Manages events and service slots. Events can be single or repeating (daily/weekly/monthly, with an end date or
ongoing). At least one serviceSlot is required at creation — each slot carries an optional configId pointing to an
EventConfig. A repeating event is stored as an EventSeries (see Event series below) whose slot blueprint is
stamped onto every generated occurrence; updating the config later propagates to all check-ins that reference it.
CreateEventDto takes no eventDate/endDate/startTime/endTime fields — all four are always derived from the
supplied serviceSlots (eventDate/endDate = earliest startTime/latest endTime, UTC-date-truncated so the
result doesn’t depend on server timezone; startTime/endTime are the same two instants kept at full precision).
This applies on both create (including each recurring occurrence, computed from its own offset-shifted slots) and
update (whenever serviceSlots is replaced). There is no longer a “manual” date range independent of the slots —
previously a caller could set an event date range that didn’t match its slot times (e.g. editing a slot’s time left
the event’s dates stale), which this removes by construction.
eventDate/endDate stay date-only because several queries filter on a calendar day (e.g. “events today or later”).
startTime/endTime exist alongside them specifically because a date-only comparison can’t tell whether an event
that ends later today has actually finished yet — getUpcomingEvents and findEventsReadyForAbsenceMarking both
filter on endTime, not endDate, for this reason, and both frontends’ “Past” badge logic does the same.
Routes prefix: /events, /event-config
Each slot can have multiple reminder schedules via sub-resource /events/slots/:slotId/reminders (admin-only). See EventReminder model.
Admin frontend UX (discuva-admin, app/events/page.tsx, components/events/event-form.tsx, utils/event-schedule.ts): the form asks for the date once and each service as a start time (<input type="time">) plus a length (30m/1h/1h30/2h chips, or separate hours and minutes fields — minutes over 59 carry into hours), with the end time shown read-only. “Add another service” starts the new row when the previous one ends and copies its config, venue and format (chainRow; “First Service” → “Second Service”). rowsToSlots converts the rows to the unchanged serviceSlots[].startTime/endTime ISO payload in the browser’s local time, and slotsToSchedule loads an existing event back into date + rows. scheduleIssues mirrors the API’s checks inline — overlap with the previous service, a missing config, and (on create) a time that has passed — and the Create button stays disabled with the first problem shown under it. A Runs over several days switch under the date (off by default; on automatically when any service has a dayOffset) adds a Day 1 / Day 2… picker to each service, shown with real dates; turning it off puts every service back on the event date. Rarely used settings (description, online attendance, per-service format and venue overrides) sit together in a More options card (per-service settings grouped by service inside it), opened automatically when any is already set; while closed it lists what’s set as chips. A Save as a service type card explains the benefit (offered under Schedule Event next time) and saves inline. Smart defaults: the config is pre-selected when there’s only one, otherwise the last one used in this browser (utils/last-used.ts, localStorage wrapped in try/catch); a blank event name becomes “{Weekday} Service” and blank service names become the event name (single service) or “First/Second… Service”. Repeats offers Doesn’t repeat / Weekly / Every 2 weeks / Monthly / Custom, with Ends Never (sends recurrence.ongoing: true) or On date. Schedule Event opens a chooser of saved service types (see below) or Blank; picking a type pre-fills the form with the next date on its saved weekday.
Reusing a past event (components/events/reuse-event-dialog.tsx, utils/event-reuse.ts): “Reuse” opens a small dialog instead of the full form. It suggests the next date on the same weekday as the original’s first slot (nextSameWeekday — today if it’s that weekday and the start time hasn’t passed, otherwise the coming one), with one-tap “Week after” / “Today” (when today’s times are still ahead) and a date picker. shiftSlotsToDate moves every slot to the chosen date keeping its local time and the day gaps between slots (day arithmetic in UTC, so DST doesn’t shift times); configs, venues and format overrides are kept. “Create event” posts straight to POST /events as a one-off (a regular service should use Repeats instead); “Edit details…” opens the usual form pre-filled with the shifted slots. A date whose times have already passed can’t be submitted — the same rule the API enforces.
Reminder dispatch (cron */15 * * * *): Queries EventReminder rows where enabled = true, lastSentAt IS NULL, fireAt <= now, and slot.startTime > now. The filter runs entirely in SQL — fireAt is pre-computed at reminder creation (and recalculated if intervalPreset is updated). When a slot is deleted or recreated (e.g., event update), its reminders are cascade-deleted. On create, fireAt = slot.startTime − preset_minutes. On update with a new intervalPreset, fireAt is recalculated from the existing slot’s startTime.
Service slot ordering: EventService.getAll(), getById(), and getUpcomingEvents() all explicitly order the serviceSlots relation by startTime ASC (query-builder .addOrderBy('serviceSlots.startTime', 'ASC') for getAll; TypeORM’s relation order option for the other two, e.g. order: { serviceSlots: { startTime: 'ASC' } }). Without this, a joined one-to-many relation has no guaranteed order — First/Second Service could come back in either order depending on DB/join internals, which showed up as the admin portal’s event list not consistently showing slots in the order they begin.
Editing an event’s slots is blocked once the event has any recorded history. EventService.update()'s slot-replacement path (slotRepository.delete + recreate) previously ran unconditionally — ServiceProgramme/ServiceSession/session-slots/action-log all cascade off ServiceSlot, and Attendance.serviceSlot is ON DELETE SET NULL, so replacing the slots on an event that had already run would silently destroy its programme/session history and detach any recorded attendance from the slot it was for. hasRecordedHistory(eventId) now checks (via two raw dataSource queries, matching attachMyAttendance’s existing pattern rather than adding new repository injections) whether any attendances row or any service_sessions row (joined through service_programmes/service_slots) exists for the event; if either does, the whole PATCH is rejected with a 400 before touching any slot. Cosmetic fields (name/description) remain editable regardless — only serviceSlots replacement is gated. This is a one-way door: once an event has history, its schedule can never be edited again, only replaced by creating a new event (a deliberate, safer default over a more capable diff-based in-place slot update, which was considered and explicitly deferred).
Slot blueprint (src/event/types/slot-blueprint.ts): series and service types store services as times of day, not instants: { name, startTime: "HH:mm", durationMinutes, dayOffset, configId?, venueOverrideId?, formatOverride? } (SlotBlueprintDto: HH:mm regex, duration 1–1440, dayOffset 0–13). blueprintToSlotDtos(blueprint, date, tz) builds each slot on date + dayOffset with fromZonedTime in the church timezone, so a 09:00 service stays 09:00 across DST changes; slotDtosToBlueprint is the inverse (via formatInTimeZone). The timezone is the tenant’s timezone column, falling back to env TIMEZONE (ChurchTimezoneService, 10-minute in-memory cache per tenant).
Event series (EventSeriesService, event_series table): every POST /events with isRecurring: true now creates a series row (pattern, interval, startDate, endDate — null when recurrence.ongoing — the slot blueprint, autoProgramme, generatedThrough, isActive) and its occurrences are ordinary events rows with recurringEventId = series.id and seriesOccurrenceDate (the church-local date they stand for; unique per series via UQ_events_series_occurrence). A fixed end date must be within a year of the start; an ongoing series has none.
- Generation:
generate(series, tz, now, until?)walks occurrence dates (k × interval days/weeks; monthly via calendar months) aftergeneratedThroughup tomin(until ?? today + 56 days, endDate), skips dates whose first service has already started or that already exist, builds each viaEventService.buildOccurrence, and advancesgeneratedThrough. Because it never revisits dates at or beforegeneratedThrough, cancelling one date (DELETE /events/:id) sticks — it is not recreated. - Top-up (
EventSeriesScheduler):@Cron('0 2 * * *'), Redis locklock:event-series-top-up(1800 s),forEachActiveTenant; tops every active series up to 8 weeks ahead in that tenant’s timezone, one series’ failure logged without stopping the rest. It only loads series that can still gain a date (generated_throughis null or before the horizon, and beforeend_datefor a fixed series), served by the partial indexIDX_event_series_active, so finished series aren’t reloaded every night. - Editing (
PATCH /events/series/:id): updates the series fields/blueprint, then applies them to upcoming occurrences withseriesOccurrenceDate >= effectiveFromthat haven’t started and have no recorded history (hasRecordedHistory). If the service names are unchanged, each occurrence’sservice_slotsrows are updated in place (times, config, venue, format) so programmes, sessions and reminders stay attached. Unsent reminders on a moved service get theirfireAtrecalculated (EventService.retimeReminders, saved through the repository soSchedulerGateSubscriberwakes theevent-remindersjob); without this they would fire at the old time. If services were added or removed, those occurrences must be recreated — the first call returns 409{ code: "SERIES_RECREATE_REQUIRED", affected }and the client resends withconfirmRecreate: true. A name-only change also renames occurrences with history. Returns{ updated, recreated, skippedWithHistory }; auditEVENT_SERIES_UPDATED. - Stopping (
POST /events/series/:id/stop{ from }): setsendDate = from − 1 day, deactivates the series and removes upcoming occurrences on/afterfromwithout history; returns{ removed }; auditEVENT_SERIES_STOPPED.DELETE /events/recurring/:idalso deactivates the series. - Older recurring groups created before series existed have no series row; they keep working as plain events but can’t be edited as a series (
GET /events/series/:id→ 404).
Programmes prepared on creation: after a single event is created, and after each series occurrence is generated, EventService.prepareProgrammes calls ServiceProgrammeService.createDraftsFromTemplates(slots): each new service slot whose name matches a programme template’s serviceSlotName (trimmed, case-insensitive) gets a DRAFT programme with the template’s items, including department assignments, and createdByAdmin = null. No notifications are sent at creation — assignees see it in My Assignments and the usual day-before reminder still goes. Slots that already have a programme are skipped; failures are logged and never block event creation. Opt out per event with autoProgramme: false on POST /events, or per series with autoProgramme on the series. The admin form lists the matching services with an opt-out checkbox.
Service types (EventTemplateService, event_templates table): a saved setup — name (unique, case-insensitive; 409 on clash), description, onlineAttendanceEnabled, slotBlueprint, defaultRecurrence ({ recurrencePattern, recurrenceInterval, ongoing, weekday? } or null; weekday 0 = Sunday lets the admin pre-fill the next matching date) and autoProgramme. Reference data, so GET /events/templates returns the full list ordered by name. Audit EVENT_TEMPLATE_SAVED / EVENT_TEMPLATE_DELETED. The admin “Save as service type…” link on the event form updates the type with the same name if one exists.
deleteEvent/deleteFutureRecurring/getAll’s upcoming filter now use precise startTime/endTime, not the date-only eventDate/endDate — same class of fix as findEventsReadyForAbsenceMarking/getUpcomingEvents above, just not originally carried through to these three call sites. Concretely: deleteEvent previously compared eventDate (start date) to today, so a same-day event that had already fully ended hours ago was still deletable; now blocks on endTime < now. deleteFutureRecurring previously selected occurrences via eventDate >= today, so an already-started (or already-ended) same-day occurrence still counted as “future”; now uses startTime >= now, and — previously entirely missing — also filters attendanceMarked = false, matching deleteEvent’s own guard (this bulk path bypasses deleteEvent entirely, so it needs the same safety check independently). getAll’s upcoming filter now matches getUpcomingEvents’ own semantics (endTime >= now) instead of showing an already-ended-today event as still upcoming.
Recurring event occurrence spacing is computed in UTC explicitly, not the runtime’s local calendar. advanceDate() used date-fns’ addDays/addWeeks/addMonths, which advance via the process’s local timezone; the resulting date-to-date millisecond delta is then applied directly to each generated occurrence’s absolute slot startTime/endTime. If the runtime’s local timezone ever observed DST, a transition between occurrences would skew every subsequent occurrence’s actual time by up to an hour — the same category of server-timezone dependence truncateToUtcDate already guards against elsewhere in this service. Rewritten to advance via setUTCDate/setUTCMonth instead, so occurrence spacing is exact regardless of server TZ.
Venue Module
Manages named venue records referenced by event configs and individual service slots. Venues decouple location data from event creation — create a venue once, reference it by ID in any config or slot.
Routes prefix: /venues
ADMIN: create, update, delete
Any authenticated user: list (full, unpaginated — admin-controlled reference data), get by ID, find nearby venues by radius
latitude and longitude must be updated together on PATCH — providing only one is rejected by validation, preventing a venue’s stored point from being silently detached from reality mid-edit.
Attendance Module
Check-in window logic:
- Window opens:
slot.startTime + workerCheckinStartOffsetSeconds(workers) or+ memberCheckinStartOffsetSeconds( members) - Window closes:
slot.startTime + checkinStopOffsetSeconds(same for all) - Workers are LATE if they check in after
slot.startTime + workerLateOffsetSeconds - Members are always PRESENT if within the window
Location is required from members too when the tenant enforces distance checking, not just workers.
Workers on an IN_PERSON slot have always had a hard requirement (checkin() throws if !dto.location), unconditional
regardless of the enforce-distance setting. Members previously had no equivalent — location is @IsOptional() on
CheckInDto, so a member could simply omit it and skip distance validation entirely (validateLocation() only
runs if (dto.location && cfg.venue)), independent of whether the tenant had enforcement turned on. Now, for an
IN_PERSON slot, a member omitting location while enforceDistance() is true gets a BadRequestException
(“Your location is required to check in for this service”); when enforcement is off, location stays fully optional
for members (matches validateLocation()'s own behavior — it never rejects on distance when unenforced, so
requiring location unconditionally would add friction with no effect).
EventConfigService.validateOffsets cross-checks checkinStopOffsetSeconds against memberCheckinStartOffsetSeconds
too, not just workerLateOffsetSeconds. Without this a config could pass every existing check yet still leave
members with an impossible window — e.g. workerCheckinStart=-600, workerLate=-30, checkinStop=-15 all validate
fine against each other, but memberCheckinStart=-10 means members’ window would only open at -10s, after
check-in had already closed at -15s.
Per-event, not per-slot, check-in dedup is intentional, not a bug. Attendance has @Unique(['member', 'event'])
— a worker rostered for multiple slots of the same event (e.g. serving both First and Second Service) checks in
once for the whole event, not once per slot. This is a deliberate compromise: requiring a separate check-in per
slot for every service someone serves in the same event was judged worse than the alternative. Do not “fix” this
by moving to a per-slot unique constraint without revisiting the product decision first.
markAbsentees() defers all followUpQueue.add() calls until the entire batch has been written without error.
The whole per-tenant cron run is one Postgres transaction (this.txHost.tx, entered by forEachActiveTenant), but
followUpQueue.add() is a Redis/Bull side effect that isn’t part of that transaction and can’t roll back with it.
Previously each event’s Bull job was enqueued immediately after its own DB writes, inside the same loop — so if a
later event in the batch threw (e.g. a race with a concurrent check-in hitting the unique constraint above),
the whole transaction rolled back, but jobs already enqueued for earlier events in that same run did not, leaving
POST_EVENT_JOBs scheduled for events whose absence rows no longer existed. Now every event’s {event} is
collected during the loop and only enqueued in a second pass after the loop completes successfully — if anything
throws mid-batch, nothing has been enqueued for any event in that run, matching the transaction’s own all-or-nothing
semantics.
AttendanceService.getBatchApprovedLeave compares leave dates against event.eventDate as a plain 'YYYY-MM-DD'
string, not the raw Date object. event.eventDate is a date column, hydrated by the pg driver as a JS Date
at local-timezone midnight; passing that Date directly as a query parameter risks the driver re-serializing it
(e.g. via UTC toISOString()) before Postgres compares it against request_leave.date_from/date_to (also date
columns), which can shift the effective calendar date by a day depending on server timezone. Formatting it as a
plain date string first (via local getters, which round-trip the same y/m/d the pg driver used to construct the
Date in the first place, regardless of what the server’s actual local timezone is) sidesteps that reinterpretation
entirely — Postgres parses the string as a DATE literal with no timezone involved.
AttendanceService.confirmOnlineAttendance no longer locks a member out mid-window if onlineAttendanceEnabled
is toggled off after the confirm emails already went out. Previously checked event.onlineAttendanceEnabled
unconditionally first — an admin disabling the toggle after onlineNotificationSentAt (but before the window
closes) meant every member clicking their already-sent confirmation link got “Online attendance is not enabled for
this event” instead of the window simply running its course. Now checks onlineNotificationSentAt first: if it’s
set, the window was already opened for this event and stays valid regardless of the toggle’s current state; the
toggle is only checked (for the clearer “not enabled” vs. “window has not opened yet” message) when the window was
never opened at all. Also now compares against this.dateService.now() instead of a bare new Date(), matching
the rest of the module’s convention.
Event audience (events.audience, audience_group_id; also on event_series and event_templates). Who an
event is for is set on the event, never on a service slot: attendance is one row per member per event, so a
per-slot audience could not be tracked or marked separately. Different audiences (a workers’ meeting, a teens class)
are separate events. EVERYONE (default) behaves as before. WORKERS / GROUP (a groups row, via
audienceGroupId) narrow, through src/event/utility/event-audience.ts:
- Already checked in — a second check-in for the same event (any service) returns 400
{ code: "ALREADY_CHECKED_IN", slotId, slotName, checkinTime }(409 with the same code on a concurrent duplicate); the member app treats it as checked in, shows the disabled “You’re checked in” state, and refreshes events when it comes back into view. Bug fixed (2026-10-06):EventService.attachMyAttendance(the source ofcheckedIn/myCheckin) and several other tenant reads —hasRecordedHistory, the contact-list audience checks,confirmOnlineAttendance’s event lookup, attendance rank/stats/history and follow-up report/pipeline queries — used the injectedDataSource, which runs on a pooled connection with the defaultpublicsearch_path, not the request’s tenant transaction. They silently readpublic.*(socheckedInwas always false whilecheckin(), using a tenant repository, correctly refused a second check-in). They now usethis.txHost.tx. Rule: tenant-table reads must go throughtxHost.tx, a tenant repository, orqueryTenant(...)— never a bareDataSource. - Check-in —
AttendanceService.assertInAudiencereturns 403 “{event} is for workers only.” / “…is for {group} only.”. - Admin / front-desk marking —
adminMarkAttendanceapplies the same check before creating a new record (correcting an existing record is still allowed). - Streaks, leaderboard, rank and attendance % are computed only from a person’s own attendance rows, so with no row ever created for people outside the audience (above), an event for others can’t break their streak or change their score.
- Absence marking —
MemberService.getMembersNotCheckedInForEvent/getWorkersNotCheckedInForEventtake the event and applyscopeToAudience, so people outside the audience are never marked absent (previously every event marked the whole congregation). - Member app visibility —
GET /eventsandGET /events/:idfrom the member surface filter witheventVisibleToViewerSql(404 for an event not meant for the caller); the admin surface sees everything. - Slot reminders — recipients are intersected with the audience, and the in-app announcement is narrowed
(
WORKERS_ONLY, orGROUPwith the event’s group).resolveAudiencevalidates the group (400 if missing) and drops a group id for any other audience. Series pass the audience to each generated occurrence, and a series edit applies it to upcoming dates. AGROUPevent whose group is later deleted (ON DELETE SET NULL) falls back to everyone. Admin: “Who is it for?” (Everyone / Workers only / A contact list —groupsare labelled Contact Lists in the admin UI) at the top of the event form, carried by service types; list and detail show “Workers only” / “{group} only”.
Check-in close rule (EventConfig.checkinCloseMode, per-slot checkinCloseModeOverride). SERVICE_END keeps
check-in open for members and workers until each service’s own endTime — no offset to tune, so one config fits services
of any length. AFTER_START closes at startTime + checkinStopOffsetSeconds (or the slot override), capped at the
service’s end. Resolution: slot.checkinCloseModeOverride ?? config.checkinCloseMode
(EventService.resolveSlotConfig); applied by AttendanceService.checkinCloseTime and mirrored by discuva-member’s
resolveSlot().checkinWindowEnd. Neither mode changes attendance status — workers are still LATE from
workerLateOffsetSeconds, and absences are still marked after the event’s endTime. Existing configs default to
AFTER_START (migration AddCheckinCloseMode); new configs from the admin form default to SERVICE_END.
EventConfigService.validateOffsets skips the stop-offset ordering checks for SERVICE_END. Admin: Event Config has a
“Check-In Closes” choice with live examples; the event form’s More options has a per-service “Check-in closes”
(Config default / When the service ends / A set time after it starts).
The config’s stop offset is a ceiling. Check-in closes at
min(startTime + checkinStopOffsetSeconds, endTime) (AttendanceService.validateCheckinWindow, mirrored by
discuva-member’s resolveSlot().checkinWindowEnd). One config therefore fits services of any length: a 60-minute
stop offset on a 30-minute service simply closes check-in when that service ends. Earlier, EventService.buildSlotFromDto
rejected any slot shorter than its config’s offset (“would leave check-in open past its own end time”), which forced
admins to create per-length configs and could also fail a series’ nightly generation; that rejection now applies
only to a per-service checkinStopOverride longer than that service (an explicit, contradictory value). Tenant
migration CapCheckinStopOffsetAtSlotEnd had already clamped existing over-long overrides; with the runtime cap
it is no longer needed for correctness but is left in place (migrations are immutable).
Attendance Distance Check Setting — two layers, per-tenant override on top of a platform-admin default.
Previously ENFORCE_DISTANCE_CHECK was a single global env var — one on/off switch shared by every tenant, no
per-church control, requiring a redeploy to change. Now two layers, same shape as the upload-limit settings above:
- Platform-wide default (
PlatformSettingKey.ENFORCE_DISTANCE_CHECK_DEFAULT,PlatformSettingsService. getEnforceDistanceCheckDefault()) — platform-admin-editable live via/platform/settings, same page as the upload limits and subscription grace period. Stored as0/1(type: 'boolean'in the response — the settings page renders a toggle instead of a number input for this one, discuva-platform’sbilling-settings/page.tsx). Unlike every otherPlatformSetting, its “no row yet” fallback is not a hardcodedKNOWN_PLATFORM_SETTINGSdefault —PlatformSettingsService.resolveDefault()reads the liveENFORCE_DISTANCE_CHECKenv var instead, specifically so shipping this didn’t silently flip behavior for any environment that already had that env var set to something other than the old default. - Per-tenant override (
AttendanceSettingsService,src/attendance/service/attendance-settings.service.ts) —ChurchSetting-backed (key: 'attendance:enforce_distance_check'), same pattern asReminderSettingsService/EmailCategorySettingsServicebut living insideAttendanceModulerather than its own top-level module, since it’s a single key, not a family.getConfig()returns{enabled, isPlatformDefault}so the admin UI can show whether a church is following the platform default or has set its own value.AttendanceService.enforceDistance()callsAttendanceSettingsService.isEnabled()(cached, tenant-scoped) on every check-in with a location — no longer a value read once at boot into a constructor field.
Routes: GET/PATCH attendances/settings/distance-check (AdminGuard, ATTENDANCE_READ/ATTENDANCE_WRITE) —
admin read/write. GET attendances/me/distance-check (JwtAuthGuard only) — member-readable mirror of the same
AttendanceSettingsService.getConfig(), added so the member app can skip its own client-side distance pre-check
when a tenant has enforcement turned off, instead of always blocking regardless of the setting. Not sensitive
data, safe for any authenticated member to read.
Frontend (admin): discuva-admin’s Event Config page (DistanceCheckBanner) — sits alongside the
per-EventConfig “Allowed Distance (meters)” field it works together with: the radius is per-config, this toggle
is tenant-wide.
Frontend (member): discuva-member’s useEvents hook fetches GET attendances/me/distance-check once and
gates its existing client-side pre-check (computed before the POST /attendances/checkin call, using the
device’s geolocation and the slot’s resolved venue/allowedDistanceInMeters) behind distanceCheckEnforced. A
distance-blocked check-in — whether caught client-side or returned by the server — gets a visually distinct “Too
Far Away” treatment (not the generic “Check-in Failed” look) via the code: 'TOO_FAR' field described below.
Too-far check-in — structured exception, not just a message string. AttendanceService.validateLocation()
throws BadRequestException({ message: 'You are too far from the venue to check in.', code: 'TOO_FAR', distanceMeters, allowedDistanceInMeters }) — extra keys beyond message are spread into the JSON response body
by the global exception filter (HttpExceptionFilter, same mechanism PlanGuard’s code: 'PLAN_UPGRADE_REQUIRED'
already uses). distanceMeters is the member’s actual computed distance (rounded), included so the frontend can
show it even when the failure came from the server rather than the client’s own pre-check.
EventConfig.enforceMemberLocation — a separate, per-config setting from distance-check above. Workers on an
IN_PERSON slot have always had a hard, unconditional requirement to submit location (checkin() throws if
!dto.location, regardless of the distance-check setting). Members had no equivalent — location is
@IsOptional() on CheckInDto, so a member could simply omit it and skip validateLocation() entirely (which
only runs if (dto.location && cfg.venue)), independent of whether distance-check enforcement was on. This setting
lets a tenant require the same of members, scoped per EventConfig (like allowedDistanceInMeters, not the
tenant-wide AttendanceSettingsService/distance-check toggle) — a church can require it for their main Sunday
service’s config while leaving a small-group config unaffected. Deliberately not merged into
enforceDistance()/the distance-check setting — the two answer different questions (“is a too-far check-in
rejected” vs. “must location be submitted at all”) and a config may want either without the other (e.g. requiring
members to share location for record-keeping without necessarily blocking anyone who happens to be far away).
- Storage:
EventConfig.enforceMemberLocation(boolean column, defaultfalse), with a per-slot override —ServiceSlot.enforceMemberLocationOverride(nullable boolean,null= inherit from config) — same override pattern ascheckinStopOverride/allowedDistanceOverride. Resolved inEventService.resolveSlotConfig()(slot.enforceMemberLocationOverride ?? config.enforceMemberLocation) alongside every other per-slot-resolved setting, soAttendanceService.checkin()reads it synchronously off the already-resolved config rather than a separate settings lookup. - Enforcement:
AttendanceService.checkin()— for anIN_PERSONslot, a member omittinglocationwhile the resolvedcfg.enforceMemberLocationistruegetsBadRequestException('Your location is required to check in for this service.'). - DTOs:
CreateEventConfigDto.enforceMemberLocation?: boolean(defaults tofalseinEventConfigService.create()when omitted, same asautoStartSession),CreateServiceSlotDto.enforceMemberLocationOverride?: boolean. - Frontend (admin): discuva-admin’s Event Config page — a toggle inside each config’s create/edit form (alongside “Auto-Start Programme”), not a standalone tenant-wide banner like distance-check. Reflected as a small “Member location required” badge on the config list row and detail view when on.
Distributed absence-marking lock: The every-5-minute cron job acquires a Redis SET NX EX 270 lock before running. If a second instance starts while the first is running, it sees the lock and skips silently. The TTL (270 s) is shorter than the cron interval (300 s) so the lock self-expires if the process crashes mid-run. Department-scoped history endpoints (/history/department, /department/event/:eventId) are automatically scoped to the caller’s own department via their lead-role assignment — no departmentId query parameter is accepted or needed.
Duplicate check-in: The (member, event) unique constraint is enforced at DB level. If a member tries to check in twice for the same event, the service catches the QueryFailedError (PG error code 23505) and returns 409 Conflict with the message “You have already checked in for this event.” checkin() itself only rejects as a duplicate when the existing row is genuinely attended
(GENUINELY_ATTENDED_STATUSES — PRESENT/LATE/ATTENDED_ONLINE, the exact set getAttendanceStreak already uses). An ABSENT/ON_LEAVE row (auto-marked before the member showed up, or left behind by an admin’s “Correct Attendance”) isn’t a real check-in, so it’s updated in place with the real check-in instead of being rejected — previously any existing row, regardless of status, blocked a fresh check-in with a misleading “already checked in at <time>” (the stale row’s leftover checkinTime), while the same status gap in EventService.attachMyAttendance (the query behind GET /events’s per-event checkedIn/myCheckin flags — see Events Module) made the member app’s own check-in button/icon look clickable at the same time — the two disagreed on what “checked in” meant. Both now use the same PRESENT/LATE/ATTENDED_ONLINE definition.
Event data on absent records: Absence records have serviceSlot = null (no physical slot was entered). History endpoints (GET /attendances/my-history, GET /attendances/history, GET /attendances/history/department) join the event relation directly on the Attendance entity rather than through serviceSlot, so event is always populated regardless of status. buildHistoryQb also joins slot.event (aliased slotEvent) separately — discuva-admin’s AttendanceServiceSlot type expects event nested under serviceSlot too (record.serviceSlot.event.name, read unguarded on the Attendance page), which the direct attendance.event join above doesn’t satisfy; omitting this second join left record.serviceSlot.event undefined for every record with a slot, crashing the whole admin Attendance page on load.
Lifetime summary (GET /attendances/my-summary), computed in SQL not client-side: Returns
{ totalCount, presentCount, attendanceRatePercentage, lastCheckedInDate, attendanceStreak } for the calling
member, via AttendanceService.getMyAttendanceSummary. This exists because the mobile app previously derived rate
and streak from whatever page of /attendances/my-history it had fetched (e.g. the last 10 records) — correct only
for a member with 10 or fewer lifetime records, silently wrong for anyone else. attendanceRatePercentage and
totalCount/presentCount are a single aggregate query (COUNT/SUM(CASE WHEN status IN (...))) over the
member’s entire history (not date-windowed, unlike the admin-dashboard-facing getPersonalAttendancePercentage
which defaults to a 30-day window) — ON_LEAVE records are excluded from both numerator and denominator (an
approved leave shouldn’t count against the rate), and PRESENT/LATE/ATTENDED_ONLINE all count as attended.
attendanceStreak delegates to the existing getAttendanceStreak (walks the last 500 records newest-first,
skip ON_LEAVE, break on ABSENT) — already used by the dashboard and already covered by the
(member, roleAtCheckin, createdAt) composite index, so no new index was needed for this endpoint.
Admin-assisted attendance (AttendanceService.adminMarkAttendance): one action covers two cases —
checking in a member/worker with no phone (no Attendance row exists yet for that member+event), and
“restoring a streak” for someone auto-marked ABSENT by the absence-marking cron (a row already exists —
this just updates it). There’s no separate streak field to repair: attendanceStreak is always computed live
from Attendance rows (see getAttendanceStreak above), so fixing/creating the row is the fix. If a record
already exists for (member, event) its status/serviceSlot are updated and checkinTime is left untouched
if already set; otherwise a new record is created with checkinTime = now, roleAtCheckin = the member’s
current role, and location: null (this is an assisted check-in, not GPS-verified). Reachable two ways:
POST /attendances/admin/mark— admin portal,AdminGuard+ATTENDANCE_WRITE.POST /attendances/department/mark— mobile app,JwtAuthGuardonly, gated in-service byassertIsAdminDeptWorker()(the caller’sworkerProfile.departmentorsecondaryDepartmentmust have theFRONT_DESK_OPERATIONScapability — the same capability idiom already used byServiceSessionService.assertIsAdminDeptWorker). Front-desk/Admin-department workers get this without needing an admin-portal login.
Both routes take { memberId, serviceSlotId, status } (AdminMarkAttendanceDto) — the slot determines the
event (slot.event), matching how self-check-in (POST /attendances/checkin) already resolves it. Audit-logged
as ATTENDANCE_ADMIN_MARKED.
Mobile member picker for admin-assisted check-in (GET /attendances/department/search-members?q=):
deliberately narrow — gated by the same assertIsAdminDeptWorker() check, bounded to 10 results
(MemberService.searchActiveMembersLite), and returns only id/firstname/lastname/role (no email or
phone) since this is a lookup for “which person is standing in front of me,” not a general member directory.
This is the one exception in the codebase to “no non-admin member-search endpoint” — justified because the
whole point of this flow is finding one named person on the spot; it’s scoped tightly enough (Admin-department
workers only, minimal fields, capped results) that it doesn’t reopen a general member-picker surface.
Email export (POST /attendances/export-email): same shared pattern as /service-headcount/export-email — filters
the same query GET /attendances/history already runs (no pagination), builds an .xlsx, and emails it via the
report-export template.
Leaderboard chart (discuva-admin, app/attendance/page.tsx): the Leaderboard tab has a Table/Chart toggle —
Chart renders a horizontal present/absent bar per worker via the same components/charts/bar-chart.tsx wrapper
introduced for the headcount trends charts. No new backend aggregation — GET /attendances/leaderboard was already
aggregate-shaped (presentCount/absentCount per worker).
Routes prefix: /attendances
Department Module
Departments are the workforce units. Each can have a head and assistant lead assigned from its worker members. The
optional key field on a department links it to a module-access category (e.g. SUNDAY_SCHOOL, CHILDREN_CHURCH,
MEDIA). Multiple departments can carry the same key.
GET /departments returns the full list (unpaginated) — department count is admin-controlled and bounded. Workers
by department (GET /departments/:id/workers) remains paginated as it can be large.
Routes prefix: /departments
discuva-admin bug fix: the departments list’s own “Leadership” column always read “No leads.”
GET /departments (the plain list call useDepartments()'s fetchDepartments uses) never included a leads
relation at all — DepartmentService.getAll() is a bare find() with no relations, and DepartmentLead isn’t
a column on Department, it’s a separate join table. The frontend’s Department.leads field was simply never
populated by anything, so the table’s Leadership column showed “No leads” for every department regardless of
actual assignment — reported live: assigning an HOD showed correctly in the detail panel (which fetches leads
via the per-department GET /departments/leads/:id endpoint) but the list row next to it stayed stale. Fixed by
a new, deliberately separate fetchAllDepartmentLeads() in hooks/use-departments.ts (one call to
GET /departments/leads, grouped client-side by department id) — not folded into fetchDepartments()
itself, since that one auto-fires on mount for every one of useDepartments()'s other consumers (Announcements,
Workers, Members, Volunteering, bulk-department/bulk-promote), several of which only need department names for
a dropdown and may not even hold DEPARTMENTS_READ — baking the leads call in there would 403 on every one of
those unrelated pages for an admin without that permission. Only app/departments/page.tsx calls the new
function (on mount, on manual Refresh, and after a successful assign/remove-lead), so the fix is scoped to
exactly where the bug was reported.
Pastor Feedback Module
A weekly, structured feedback channel from departments up to the pastorate — the department’s HOD or Assistant HOD (D_HOD) submits it; a pastor reads and responds, from either the admin portal or the mobile app.
Three controllers, one service:
pastor-feedback-worker.controller.ts(JwtAuthGuard) —POST /pastor-feedback(submit),PATCH /pastor-feedback/:id(edit own),GET /pastor-feedback/my(own history). Ownership is checked in-service (HOD/D_HOD of the target department), not by role alone.pastor-feedback-admin.controller.ts(AdminGuard) — cross-department browse/edit/delete (PASTOR_FEEDBACK_READ/WRITE), plusPOST /pastor-feedback/admin/:id/respondfor an admin whose linkedMemberhas aPastorrecord.pastor-feedback-pastor.controller.ts(JwtAuthGuard, mobile-facing) — same cross-department browse plusPOST /pastor-feedback/pastor/:id/respond, gated byassertIsPastor()(anyPastorrecord) rather than an admin permission.
Weekly reminder scheduler (PastorFeedbackReminderScheduler): @Cron('0 9 * * 1', { timeZone: CHURCH_TIMEZONE }) — Monday 9am. Computes weekOf as the Monday of the week that just closed, finds every Department with no PastorFeedback row for that week, and emails + pushes a reminder to the department’s HOD (falling back to the D_HOD if no HOD is assigned; skipped entirely if neither exists). Unlike PrayerReminderScheduler, no reminderSent boolean is needed — the row’s absence is the “still pending” signal, and push de-duplication relies on PushNotificationService’s idempotencyKey (pastor-feedback-reminder:{departmentId}:{weekOf}).
Routes prefix: /pastor-feedback, /pastor-feedback/admin, /pastor-feedback/pastor
Rename data-migration note: the original Department Feedback → Pastor Feedback rename only renamed the
AdminPermission enum values in code (department_feedback:read/write → pastor_feedback:read/write) — it did
not touch already-granted AdminRole.permissions (a raw text[] column checked via a plain .includes() in
AdminGuard, not a normalized join table). Any role granted the old strings before the rename silently lost
access with no error. Fixed by 1788998400000-FixStalePastorFeedbackPermissions.ts, which array_replaces the
old strings for the new ones on every existing admin_roles row. General lesson: any future rename of an
AdminPermission enum value needs a matching data-migration for admin_roles.permissions, not just the code
rename — the enum change alone never reaches rows that already exist.
Prayer Request Module
Lets any member/worker submit a private prayer request and, separately, share an opt-in public testimony —
either tied to one of their own prayer requests or general. This is distinct from the Prayer Roster Module below,
which schedules workers into prayer-meeting duty slots; this module is member-submitted requests with a lifecycle
(OPEN → PRAYED_FOR → ANSWERED), unrelated to any meeting.
Visibility:
- Prayer requests are visible only to the submitter, workers in the Prayer department, and pastors — never a
public wall. Testimonies default to private; the submitter alone decides at submission time whether theirs
appears on the shared public feed (
isPublic) — there is no separate admin moderation/publish step.
Entities: PrayerRequest (prayer_requests) and Testimony (testimonies), both with a nullable
member FK (SET NULL) plus a submittedByName snapshot, mirroring PastorFeedback.submittedBy’s pattern so a
deactivated member’s history survives. A Testimony.prayerRequest FK (nullable, SET NULL) links it to a specific
request; null means a general testimony not tied to any request.
Three controllers, one service (PrayerRequestService):
prayer-request-worker.controller.ts(JwtAuthGuard) —POST /prayer-requests(submit),GET /prayer-requests/mine(own history),POST /testimonies(submit, optionalprayerRequestId— enforced to be the caller’s own request),GET /testimonies/mine,GET /testimonies/public(the opt-in feed, open to any authenticated member/worker).prayer-request-team.controller.ts(JwtAuthGuard, mobile-facing) —GET /prayer-requests/team,PATCH /prayer-requests/team/:id/status. Gated in-service byassertIsPrayerTeamOrClergy(): any worker whose primary or secondary department has theMANAGE_PRAYER_REQUESTScapability, or any member with aClergyrecord.prayer-request-admin.controller.ts(AdminGuard) —GET /prayer-requests/admin,PATCH /prayer-requests/admin/:id/status,GET /testimonies/admin(full visibility, not just public ones). Reuses the existingPRAYER_READ/PRAYER_WRITEpermissions (already grouped under “Prayer Roster” inAdminPermissionGroups) — no new permission was introduced, since this is the same overall “Prayer” domain.
Routes prefix: none fixed — routes span /prayer-requests* and /testimonies* across the three controllers
(each controller uses @Controller() with a full path per handler rather than a single shared prefix, since the
two resources don’t share one).
Pregnancy prayer tracking: the same module also tracks pregnant women receiving ongoing prayer support —
PregnancyPrayerCase (name, EDD, details, status) plus PregnancyPrayerVisit (a log entry per prayer/visit,
mirroring the FirstTimerVisit idiom in the Follow-Up module). Unlike prayer requests, these are created and
managed entirely by the Prayer team/clergy on the woman’s behalf — there is no worker-facing self-submit
controller. PregnancyPrayerCase.lastPrayedAt is denormalized and updated whenever a new visit is logged, so the
UI can show “last prayed” without joining the visit log on every read. Reuses PRAYER_READ/PRAYER_WRITE — no new
permission. Every PregnancyPrayerVisit is also readable back via GET prayer-requests/team/pregnancy-cases/:id/visits (mobile) and GET prayer-requests/admin/pregnancy-cases/:id/visits (admin, PRAYER_READ) — paginated, newest first, so the full
visit-and-note history is reviewable, not just the denormalized lastPrayedAt date. Routes: GET/POST prayer-requests/team/pregnancy-cases, POST prayer-requests/team/pregnancy-cases/:id/visit, PATCH prayer-requests/team/pregnancy-cases/:id/status, GET prayer-requests/team/pregnancy-cases/:id/visits (mobile,
assertIsPrayerTeamOrClergy gated) and the parallel GET prayer-requests/admin/pregnancy-cases, PATCH prayer-requests/admin/pregnancy-cases/:id/status, GET prayer-requests/admin/pregnancy-cases/:id/visits (admin
portal oversight, read + status-only — case creation and visit logging stay Prayer-team/mobile-only by design).
Leave Module
Workers request leave with a date range. Approved leave is checked by the cron job: if a worker has approved leave overlapping a slot’s time range, they are marked ON_LEAVE instead of ABSENT.
Submission guards:
- A worker with a
PENDINGrequest cannot submit another until the first is actioned. - A worker cannot submit a request whose date range overlaps any already-approved leave (
dateFrom ≤ request.dateTo AND dateTo ≥ request.dateFrom). Returns400 Bad Request.
Date columns (dateFrom, dateTo): stored as PostgreSQL date (no time component, format YYYY-MM-DD). Overlap checks compare date strings to avoid timezone shifts.
Routes prefix: /leave
Classes Module (displayed as “Training Classes”)
Tracks member progress through structured church programs. The module, route path (/classes), and permission keys (classes:read/classes:write) are unchanged — only the user-facing label was renamed from “Classes”/“Church Classes” to “Training Classes” (sidebar nav, breadcrumbs, page headings, permission labels). Renaming the permission enum keys would strand existing admin_roles.permissions rows (see the Pastor Feedback rename note under the Pastor Feedback Module section), so only display strings changed.
Class types: admin-defined via ClassType CRUD (/classes/types) — not a fixed enum. Each type optionally points to a nextClassType, forming an admin-configured promotion chain (see the ClassType entity section above). Deactivating a type (isActive: false) hides it from new-class pickers without breaking existing classes that reference it.
Enrollment statuses: IN_PROGRESS → COMPLETED or CANCELLED. A COMPLETED enrollment whose class type has a nextClassType becomes eligible for level promotion (GET classes/enrollments/:id/promotion-candidate → POST classes/enrollments/:id/promote) — an explicit, separate, admin-confirmed action, not automatic.
Certificates: A COMPLETED enrollment can be marked as having received a certificate via PATCH classes/enrollments/:id/certificate (optional certificateNumber; auto-numbered when omitted, and downloadable as a PDF — see “Sessions, attendance… certificates” below).
Guest enrollment: non-members can take a class alongside members — see the Guest/ClassEnrollment entity sections above for the data model and portal-access mechanics. POST classes/enroll/guest (body: EnrollGuestDto — classId + either guestId for a returning guest, or firstName/lastName/email(+phone/churchName/address/notes) for a new one, + optional per-enrollment purpose) enrolls a single guest, finding-or-creating the Guest row by email and sending the class-guest-access portal-link email on a fresh enrollment (not on re-enrollment of a CANCELLED row). POST classes/enroll/guests/bulk (body: BulkEnrollGuestsDto — classId + guests: {firstName, lastName, email, phone?}[]) loops the same logic per entry, catching and logging per-entry failures rather than aborting the whole batch, and returns { enrolled, skipped } — mirroring bulkEnrollMembers’s all-or-nothing-per-row (not all-or-nothing-per-batch) behavior. Both are blocked (400) against a CLOSED class.
Guest management (GET classes/guests, GET classes/guests/:id): GET classes/guests is a paginated, search-by-name/email list of every guest across all classes (permission CLASSES_READ) — the answer to “how does an admin see/manage a guest across multiple classes” without digging through one class’s enrollment tab at a time. GET classes/guests/:id returns one guest’s profile plus every ClassEnrollment they’ve ever had, across all classes.
Guest-to-member conversion: POST classes/guests/:guestId/convert-to-member (permission CLASSES_WRITE) — see the Guest entity section above.
Guest portal access (no login): GET classes/guest/:enrollmentId and POST classes/guest/:enrollmentId/assignments/:assignmentId/submit, both @Public() (bypass JwtAuthGuard; ModuleEnabledGuard still applies since it keys off the tenant, not caller auth) on a dedicated ClassPublicController — mirrors the Forms module’s public-submission pattern exactly. The submit route is rate-limited (5/min). submitAsGuest verifies the enrollment actually has a guest attached and belongs to the same class as the assignment, so neither a member’s enrollment id nor a guest’s enrollment in a different class can be used to submit.
Study materials — see the ClassMaterial entity section above for the full model (multiple titled
documents/links per class, upload vs. pasted-link vs. reuse-existing-asset, and the reference-counted delete
behavior that keeps a shared “reused” upload safe). Upload accepts PDF, Word, PowerPoint, or image mimetypes; size
is capped by PlatformSettingKey.MAX_CLASS_MATERIAL_UPLOAD_MB (platform-admin-configurable, default 10 MB —
separate from the app-wide MAX_FILE_UPLOAD_BYTES default of 5 MB since course material tends to run larger than
proofs/images). Materials can only be added to a class that already exists — there’s no material field on
POST classes itself.
Assignments (Assignment/AssignmentSubmission, tables assignments/assignment_submissions): each
ChurchClass can have any number of assignments. An assignment has title, optional instructions, maxScore
(default 100), optional dueDate, and isPublished (default true — an unpublished assignment is an admin-only
draft, invisible to students and not submittable). A student submits free-text content against a published
assignment; one submission per member per assignment (UNIQUE(assignment_id, member_id)) — resubmitting before
grading overwrites the existing row (submittedAt bumped), but once graded (gradedAt set) further resubmission
is rejected with 400, so a grade can’t be silently invalidated by a late edit. Grading (PATCH classes/assignments/submissions/:submissionId/grade) sets score (validated <= assignment.maxScore, 400
otherwise), optional feedback, and stamps gradedBy (the grading Admin, resolved via @CurrentAdmin() — not
the JWT’s member id) + gradedAt. gradedBy is SET NULL on admin deletion, mirroring the reviewedBy pattern
used by tithe/finance proof review.
Guest submissions: AssignmentSubmission.member is nullable; a guest’s submission is keyed by classEnrollment (ManyToOne → ClassEnrollment, onDelete: CASCADE) instead — DB-level CHECK constraint (member_id IS NOT NULL) != (class_enrollment_id IS NOT NULL) (exact XOR, unlike ClassEnrollment’s own OR constraint): a submission is made either as an authenticated member OR via a specific guest enrollment, never both, and converting a guest later doesn’t rewrite past submissions. UNIQUE(assignment_id, class_enrollment_id) mirrors the member-side UNIQUE(assignment_id, member_id). submitAsGuest() is the guest-portal equivalent of submit() — same overwrite-before-grading / reject-after-grading rules, reached only via ClassPublicController (see Guest portal access above).
Progress summary: both GET classes/:classId/assignments/available (member) and GET classes/guest/:enrollmentId (guest) return { assignments: [...], progress: { submitted, total } } — the progress summary is derived from the same published-assignments-plus-submissions query already being run, not a separate round-trip. Breaking change from the prior shape: available used to return a bare Assignment[]; callers must now read .assignments.
| Method | Route | Auth | Notes |
|---|---|---|---|
| POST | /classes/:classId/assignments |
AdminGuard (CLASSES_WRITE) | Create an assignment for a class |
| GET | /classes/:classId/assignments |
AdminGuard (CLASSES_READ) | All assignments for a class, including unpublished drafts |
| GET | /classes/:classId/assignments/available |
JwtAuthGuard | { assignments, progress } — published assignments only, each merged with the caller’s own mySubmission (null if not yet submitted) |
| PATCH | /classes/assignments/:assignmentId |
AdminGuard (CLASSES_WRITE) | Partial update |
| DELETE | /classes/assignments/:assignmentId |
AdminGuard (CLASSES_WRITE) | Cascades submissions |
| POST | /classes/assignments/:assignmentId/submit |
JwtAuthGuard | Create or (if ungraded) overwrite the caller’s own submission |
| GET | /classes/assignments/:assignmentId/submissions |
AdminGuard (CLASSES_READ) | Paginated (?page=&limit=), for grading |
| PATCH | /classes/assignments/submissions/:submissionId/grade |
AdminGuard (CLASSES_WRITE) | { score, feedback? } |
| POST | /classes/:id/materials/upload |
AdminGuard (CLASSES_WRITE) | Multipart, field file (+ optional title) — uploads to Cloudinary and creates the ClassMaterial row |
| POST | /classes/:id/materials/link |
AdminGuard (CLASSES_WRITE) | { title, url } — pasted external link, no Cloudinary asset |
| POST | /classes/:id/materials/reuse |
AdminGuard (CLASSES_WRITE) | Echoes a library entry’s fields — new row, same underlying asset, no re-upload |
| DELETE | /classes/:id/materials/:materialId |
AdminGuard (CLASSES_WRITE) | Reference-counted Cloudinary cleanup — see ClassMaterial above |
| GET | /classes/materials/library |
AdminGuard (CLASSES_READ) | { title, url, publicId, resourceType, mimeType, sizeBytes, usedByClassNames }[] — for the “Reuse Previous” picker |
| GET | /classes/lookup |
AdminGuard (ANNOUNCEMENTS_WRITE) | {id, name, startDate, endDate}[] — feeds the Announcements CLASS-audience picker; gated on ANNOUNCEMENTS_WRITE (composing an announcement), not CLASSES_READ, mirroring GET /groups/lookup |
| PATCH | /classes/:id/session |
AdminGuard (CLASSES_WRITE) | { nextSessionAt?, meetingLink? } — either can be set to null to clear |
| POST | /classes/enroll/guest |
AdminGuard (CLASSES_WRITE) | EnrollGuestDto — see Guest enrollment above |
| POST | /classes/enroll/guests/bulk |
AdminGuard (CLASSES_WRITE) | BulkEnrollGuestsDto → { enrolled, skipped } |
| GET | /classes/guests |
AdminGuard (CLASSES_READ) | Paginated, ?search= by name/email |
| GET | /classes/guests/:id |
AdminGuard (CLASSES_READ) | Guest profile + every enrollment across all classes |
| POST | /classes/guests/:guestId/convert-to-member |
AdminGuard (CLASSES_WRITE) | See Guest-to-member conversion above |
| GET | /classes/guest/:enrollmentId |
@Public() |
Class info (incl. nextSessionAt/meetingLink) + guest name + assignments + progress, plus schedule (with myStatus), attendance summary and enrollment certificate status |
| POST | /classes/guest/:enrollmentId/assignments/:assignmentId/submit |
@Public(), rate-limited (5/min) |
{ content } |
Assignments reuse the existing classes:read/classes:write permissions rather than adding new ones — managing
assignments is the same admin surface as managing the classes they belong to.
Sessions, attendance, progress, facilitators, requests, certificates, reports (added 2026-10-01)
New tables (tenant migration 1799910000000-AddTrainingClassSessionsAndRequests): class_sessions,
class_session_attendances, class_join_requests; new church_classes columns (see ChurchClass); and
assignment_submissions.graded_by_member_id. All routes live on ClassTrainingController, registered before
ClassesController so literal paths (teaching, reports, my/..., join-requests/...) aren’t captured by :id.
Sessions (ClassSession): one scheduled day of a class — startsAt, optional endsAt, mode
(PHYSICAL/VIRTUAL/HYBRID), optional title, location, meetingLink, notes. Create one, or a series
({ startDate, endDate, time: "HH:mm", durationMinutes?, everyWeeks: 1–4, title?, mode, location?, meetingLink?, notes? })
— every N weeks at the same local time in the church timezone (wallTimeToUtc, DST-safe), ≤60 per call, skipping
start times that already exist; returns { created, skipped: date[] }. Every session ends: without endsAt (single) or
durationMinutes (series) the session lasts 2 hours (DEFAULT_SESSION_MINUTES); moving a session’s start without a
new end keeps its length, and sending endsAt: null resets it to 2 hours. Migration
1800082800000-BackfillClassSessionEndTimes gives any older session without an end the same default. A session with attendance can’t be deleted (400),
only edited. Every create/edit/delete re-syncs the class’s nextSessionAt/meetingLink, and
ClassSessionReminderScheduler calls advancePastNextSessions() each hour before its sweep, so existing session
reminders follow the schedule.
Attendance (ClassSessionAttendance): keyed by enrollment (members and guests alike), UNIQUE(session, enrollment),
status PRESENT/ABSENT/EXCUSED, markedByAdmin or markedByMember (facilitator). Roster = every non-cancelled
enrollment with its mark; marking upserts and silently skips enrollments not on the class.
Progress & completion rules (ClassProgressService): per non-cancelled enrollment — sessions held since the day
they enrolled (church timezone), present/absent/excused, attendancePercent = present ÷ (held − excused), published
assignments submitted, averageScorePercent (graded scores as % of each max), meetsRules, and missing[]
(plain-language reasons). Closing a class (PATCH /classes/:id/close) now returns
{ closedEnrollments, needsReview: [{ enrollmentId, name, missing }] }: with rules set, only IN_PROGRESS people who meet
them are completed and the rest stay IN_PROGRESS for an admin decision; with no rules, everyone is completed as before.
Facilitators (member app): a member listed as a ClassFacilitator of a class can, for that class only, manage
sessions, mark attendance, see progress, list assignments with submitted/graded counts and grade submissions
(gradedByMember is set instead of gradedBy). Non-facilitators get 403. GET /classes/teaching lists the caller’s classes.
Join requests (ClassJoinRequest): members ask to join a class with openForRequests; refused if the class is
closed, full (capacity vs IN_PROGRESS count), they’re already in it (a CANCELLED enrolment may ask again) or they
already have a PENDING request (partial unique index UQ_class_join_requests_pending). Approving enrols them via
ClassesService.enrollMember and pushes CLASS_JOIN_APPROVED; declining stores an optional reason and pushes
CLASS_JOIN_DECLINED. Members can withdraw a pending request. GET /classes/:id/join-status tells the app where the
caller stands (openForRequests, classClosed, capacity, spotsLeft, enrollmentStatus, enrollmentId, latest request).
Certificates: issuing without a number now assigns the next CERT-YYYY-NNNN (per calendar year, serialised with
pg_advisory_xact_lock, ordered by length then value) and pushes CLASS_CERTIFICATE_READY to members. A typed number is
kept as given. POST /classes/:id/certificates/issue-all issues for every COMPLETED enrollment without one. The PDF
(PdfService.generateClassCertificate, landscape A4) uses the tenant’s name and logo, the class and class-type name,
the completion date and the first two facilitators as signatories; available to admins, to the member (own enrollment
only) and to guests via their portal link.
Reports (ClassReportService): classes running in [from, to] (open-ended dates count) optionally by
classTypeId: per class enrolled/in progress/completed/cancelled, completionRate (completed ÷ everyone enrolled),
sessions held, averageAttendance (mean of people’s attendance %), certificates; totals; and next steps — for each
class type with a nextClassType, members who completed it (completedAt in range) and how many have a non-cancelled
enrollment in the next type. Excel export: Classes, Next steps, People sheets.
Performance & indexes: ClassProgressService.progressFor(classIds, enrollmentIds?) computes any number of classes
(or just some enrollments) in a fixed set of queries — reports call it once for every class in range, and a member’s
or guest’s own view asks for their enrollment only. Member/guest schedules skip the attendance totals and reuse the
session list. Next-steps uses one CTE query per class type. issue-all takes the numbering lock once, saves every
certificate in one write and sends one push. Indexes: class_sessions(church_class_id, starts_at);
class_session_attendances UNIQUE (session_id, enrollment_id) + (enrollment_id); class_join_requests
(church_class_id), (member_id) and the partial unique (church_class_id, member_id) WHERE status='PENDING' (also
serves pending counts); assignment_submissions(graded_by_member_id); and
IDX_class_enrollments_certificate_number (text_pattern_ops, partial on non-null, migration
1799996400000-AddClassEnrollmentCertificateNumberIndex) for the LIKE 'CERT-YYYY-%' lookup. Existing indexes cover
the rest (class_enrollments by class and the unique (member_id, church_class_id), class_facilitators(member_id),
assignments(church_class_id), assignment_submissions(assignment_id)).
Notifications: new EmailCategory.TRAINING_CLASSES (“Training Class Updates”, env EMAIL_TRAINING_CLASSES_ENABLED),
push only: CLASS_JOIN_APPROVED, CLASS_JOIN_DECLINED, CLASS_CERTIFICATE_READY.
| Method | Route | Auth | Notes |
|---|---|---|---|
| GET | /classes/:id/sessions |
AdminGuard (CLASSES_READ) | Sessions with held and attendance counts |
| POST | /classes/:id/sessions |
AdminGuard (CLASSES_WRITE) | { startsAt, endsAt?, title?, mode?, location?, meetingLink?, notes? } |
| POST | /classes/:id/sessions/series |
AdminGuard (CLASSES_WRITE) | See Sessions above → { created, skipped } |
| PATCH | /classes/sessions/:sessionId |
AdminGuard (CLASSES_WRITE) | Any session field; null/empty clears optional text |
| DELETE | /classes/sessions/:sessionId |
AdminGuard (CLASSES_WRITE) | 400 once attendance exists |
| GET | /classes/sessions/:sessionId/roster |
AdminGuard (CLASSES_READ) | { session, entries: [{ enrollmentId, name, email, isGuest, status }] } |
| POST | /classes/sessions/:sessionId/attendance |
AdminGuard (CLASSES_WRITE) | { attendances: [{ enrollmentId, status }] } (≤500) → { marked } |
| GET | /classes/:id/progress |
AdminGuard (CLASSES_READ) | { rules, sessionsHeld, sessionsTotal, people[] } |
| PATCH | /classes/:id/close |
AdminGuard (CLASSES_WRITE) | → { closedEnrollments, needsReview[] } (rule-aware) |
| GET | /classes/:id/join-requests |
AdminGuard (CLASSES_READ) | Pending first, then recent decisions (≤200) |
| GET | /classes/join-requests/pending-counts |
AdminGuard (CLASSES_READ) | { [classId]: count } |
| POST | /classes/join-requests/:requestId/approve |
AdminGuard (CLASSES_WRITE) | Enrols the member → the enrollment |
| POST | /classes/join-requests/:requestId/decline |
AdminGuard (CLASSES_WRITE) | { reason? } |
| POST | /classes/:id/certificates/issue-all |
AdminGuard (CLASSES_WRITE) | → { issued } |
| GET | /classes/enrollments/:enrollmentId/certificate |
AdminGuard (CLASSES_READ) | |
| GET | /classes/reports/summary?from&to&classTypeId |
AdminGuard (CLASSES_READ) | { from, to, totals, classes[], pipeline[] } |
| GET | /classes/reports/export?from&to&classTypeId |
AdminGuard (CLASSES_READ) | .xlsx |
| GET | /classes/teaching |
JwtAuthGuard | Classes the caller facilitates, with enrolledCount |
| GET | /classes/teaching/:id/sessions |
Facilitator of the class | As the admin list |
| POST | /classes/teaching/:id/sessions |
Facilitator of the class | Create a session |
| POST | /classes/teaching/:id/sessions/series |
Facilitator of the class | Create a series |
| PATCH | /classes/teaching/sessions/:sessionId |
Facilitator of the class | Edit a session |
| DELETE | /classes/teaching/sessions/:sessionId |
Facilitator of the class | Delete (no attendance yet) |
| GET | /classes/teaching/sessions/:sessionId/roster |
Facilitator of the class | Roster |
| POST | /classes/teaching/sessions/:sessionId/attendance |
Facilitator of the class | Mark attendance (markedByMember) |
| GET | /classes/teaching/:id/progress |
Facilitator of the class | Progress |
| GET | /classes/teaching/:id/assignments |
Facilitator of the class | Assignments with submittedCount, gradedCount |
| GET | /classes/teaching/assignments/:assignmentId/submissions |
Facilitator of the class | Paginated submissions (members and guests) |
| PATCH | /classes/teaching/submissions/:submissionId/grade |
Facilitator of the class | { score, feedback? } |
| GET | /classes/:id/my-progress |
JwtAuthGuard | { enrolled, schedule[], progress, rules, join } — schedule for anyone; own marks/progress only when enrolled; join is the same object as /join-status so the class page needs one call |
| GET | /classes/:id/join-status |
JwtAuthGuard | See Join requests above |
| POST | /classes/:id/join-requests |
JwtAuthGuard | { message? } |
| DELETE | /classes/join-requests/:requestId |
JwtAuthGuard | Withdraw own pending request (204) |
| GET | /classes/my/join-requests |
JwtAuthGuard | Own requests (≤50) |
| GET | /classes/my/enrollments/:enrollmentId/certificate |
JwtAuthGuard | Own certificate PDF |
| GET | /classes/guest/:enrollmentId/certificate |
@Public(), 10/min |
Guest’s certificate PDF |
Routes prefix: /classes, /classes/types
Announcements Module
Audience-targeted broadcast messages. The /announcements/feed endpoint filters automatically based on the caller’s
role and optional departmentId.
Audience rules:
- MEMBER → sees
ALL+MEMBERS_ONLY+ anyINDIVIDUALannouncements addressed to them + anyGROUPannouncement for a group they belong to + anyCLASSannouncement for a class they’re enrolled in (IN_PROGRESS/COMPLETED, as a member — guests have no login and so never see the feed) - WORKER → sees
ALL+WORKERS_ONLY+DEPARTMENT(for their department) +INDIVIDUAL(addressed to them) + anyGROUPannouncement for a group they belong to + anyCLASSannouncement for a class they’re enrolled in - ADMIN → sees all audiences
- Expired announcements (
expiresAt < now) are excluded from the feed
Audience types: ALL | WORKERS_ONLY | MEMBERS_ONLY | DEPARTMENT | INDIVIDUAL | GROUP | CLASS
When audience = DEPARTMENT, departmentId is required. When audience = INDIVIDUAL, targetMemberId (UUID) is
required. When audience = GROUP, groupId (UUID) is required. When audience = CLASS, classId (UUID) is
required. Since ChurchClass already has startDate/endDate — each row is one dated cohort — picking a specific
class already scopes the audience to a specific date range; there’s no separate date-range picker. CLASS audience
targets IN_PROGRESS + COMPLETED enrollees only (excludes CANCELLED).
Push notification on every audience: AnnouncementService.create() always fire-and-forgets a single PushNotificationService.dispatchToMemberIds() call after saving, for every audience type — not just GROUP. resolveMemberIdsForAudience() computes the recipient member-id list per audience (GROUP → GroupService.getMemberIdsForGroup; CLASS → ClassesService.getMemberIdsForClass — member-linked enrollees only, guests have no member id to push to; INDIVIDUAL → the single targetMember; ALL/MEMBERS_ONLY/WORKERS_ONLY/DEPARTMENT → an ACTIVE-member query filtered by role/department, mirroring resolvePhoneNumbers’s SMS-targeting logic but without requiring a phone number on file). No push is sent when the resolved list is empty. Idempotency key = the announcement id, so a retry or duplicate call never double-sends. Failure to dispatch is logged as a warning and never fails the announcement creation itself — the announcement is still visible in-app via the feed regardless of push delivery outcome.
Optional SMS delivery (sendViaSms/smsBody): CreateAnnouncementDto/UpdateAnnouncementDto accept
sendViaSms?: boolean and smsBody?: string. Setting sendViaSms: true requires the caller’s admin role to hold
the SMS_SEND permission (checked in AnnouncementService, not the DTO — a DTO can’t inspect the caller’s
permission set — throws 403 Forbidden otherwise) and requires smsBody to be non-empty. smsBody is deliberately
separate from body: the announcement body is often long-form and meant for in-app reading, whereas SMS is billed
per segment, so admins compose a distinct, short message for it. SMS sends are allowed from 08:00 through 19:50 in
CHURCH_TIMEZONE (default Africa/Lagos) for every provider. On create, an SMS is sent (awaited) whenever
sendViaSms is set; the announcement is still saved if sending fails, and the response includes a transient
smsDispatch result (accepted, failed, or skipped) and any provider error message. On update, the SMS is sent
only on the transition into sendViaSms=true — re-saving an already-SMS’d announcement (e.g. editing its title
afterward) does not re-text everyone. accepted means the provider accepted the request, not that the carrier
delivered every message; use the SMS Logs page for current provider delivery status and its matching local send
record, including the announcement ID and any provider error.
SMS phone number resolution (resolvePhoneNumbers) — independent of the push-notification audience logic above.
Always restricted to ACTIVE members with a non-null phoneNumber, further filtered by audience:
ALL— every eligible memberMEMBERS_ONLY— role = MEMBERWORKERS_ONLY— role = WORKERDEPARTMENT— workers whoseworkerProfile.departmentmatchesannouncement.departmentINDIVIDUAL— justannouncement.targetMemberGROUP— members resolved viaGroupService.getMemberIdsForGroup(announcement.group.id)CLASS— union of member-linked enrollees’ phones (ClassesService.getMemberIdsForClass, filteredACTIVE+phone-on-file like every other audience) and guest enrollees’ own phones (ClassesService.getGuestPhonesForClass— a guest has noMemberrow, so their ownGuest.phone, if set, is the only channel besides email), deduped — the same dual-source shaperesolveGroupPhoneNumbersalready uses for member vs. phone-onlyGroupMemberentries
resolvePhoneNumbers takes a plain { audience, departmentId?, targetMemberId?, groupId?, classId? } target rather than an
Announcement entity, so it’s reusable outside the announcement-creation flow — see sendSmsBroadcast below.
SMS-only broadcast (POST /announcements/sms-broadcast), no announcement created: For sending a text blast to an
audience without publishing anything to the in-app feed. Guarded solely by SMS_SEND (not ANNOUNCEMENTS_WRITE —
an admin with SMS access but no announcement-authoring access can use this). Body: SendSmsBroadcastDto —
audience (required) + the matching departmentId/targetMemberId/groupId/classId for DEPARTMENT/INDIVIDUAL/GROUP/CLASS
audiences + message (required). Reuses the same resolvePhoneNumbers targeting as sendViaSms on a regular
announcement. No Announcement row is created, no push notification is sent, and no title/body is required — this
is purely an SMS send. Returns { sentCount }; sentCount: 0 (not an error) when the resolved audience has no
members with a phone number on file. Returns { sentCount, failedCount?, failures? }; failed recipients are returned
as a normal response so the tenant transaction can commit their local failure records. Failures are audited as
SMS_BROADCAST_FAILED, not as sent. A failure may follow partial provider acceptance, so check SMS Logs before
retrying. Successful requests log SMS_BROADCAST_SENT (metadata: { audience, count }); sentCount records
provider acceptance, not carrier delivery.
Emoji reactions: any authenticated member/worker can react to an announcement with one of a fixed emoji set
(ReactionEmojiEnum: 👍 ❤️ 🙏 🎉 👏) via POST announcements/:id/react — reacting again just updates the existing
reaction (one per member per announcement, not multi-emoji). DELETE announcements/:id/react removes it.
GET announcements/:id/reactions returns { summary: { emoji, count }[], myReaction: string | null } —
myReaction reflects the calling member’s own reaction so the frontend can highlight it without a separate
lookup. No audit logging on reactions — too high-frequency/low-stakes to be worth an audit trail entry per click.
System-triggered announcements (createSystemAnnouncement): a small internal entry point used by features that
need to publish an ALL-audience announcement without going through the admin-authored create() flow — no
Admin/SMS-permission check, author is left null (the FK is nullable for exactly this reason), publishedAt is
now, and the same persist + push-notify path runs. Used today by the Sermon Archive’s “Announce Live” trigger (see
Sermon Module); designed to also be the target of the planned YouTube WebSub livestream-detection integration.
Routes prefix: /announcements
Picking a group when creating a GROUP announcement does not require groups:read/groups:write — the admin
frontend’s group picker (GroupSearchInput, a searchable combobox filtering the already-fetched list client-side
rather than a plain <select>) calls GET /groups/lookup via useGroupLookup() (see Groups Module), gated on
announcements:write only, since choosing a group here is a component of the announcement feature rather than a
separate group-management capability.
Picking a class when creating a CLASS announcement mirrors the group picker exactly, one permission layer down: ClassSearchInput calls GET /classes/lookup (see Classes Module — same announcements:write-only gating, same reasoning), showing each class’s name plus its startDate/endDate so an admin can distinguish cohorts of the same class type.
Groups Module
Reusable, admin-managed rosters of members and/or workers (e.g. “Call Leaders”) used to target announcements at a
fixed group of people without re-selecting individuals each time. A group’s membership is independent of
Department — a group can mix members and workers from any department.
Entities:
Group(groupstable) —name(unique),description(nullable),createdBy(nullable FK →members,SET NULL).GroupMember(group_memberstable) — join entity. A row is either a real Member (memberFK set) or a phone-only entry (phoneNumber+ optionallabelset,membernull) — e.g. a manually-typed number, or one imported from aFirstTimerwho has noMemberaccount (first-timers can’t join Groups any other way, since they aren’tMemberrecords). Enforced by aCHECKconstraint (member_id IS NOT NULL AND phone_number IS NULLOR the reverse) added in migrationAddGroupMemberPhoneEntries, plus service-layer logic that only ever sets one side.@Unique(['group', 'member'])and@Unique(['group', 'phoneNumber'])both apply — Postgres treats NULLs as distinct per unique index, so real-member rows (phoneNumber null) and phone-only rows (member null) don’t collide with each other’s constraint.group/memberFKsON DELETE CASCADE(removing a group or a member cleans up membership rows automatically),addedBy(nullable FK →members,SET NULL).
Permissions: groups:read, groups:write (grouped under “Announcements” in AdminPermissionGroups, since a group’s only current purpose is targeting announcements).
Routes prefix: /groups
| Method | Path | Description |
|---|---|---|
| GET | /groups |
List all groups with memberCount (no pagination — reference data, mirrors the Departments policy). memberCount counts all group_members rows regardless of kind, so phone-only entries count too. |
| GET | /groups/lookup |
Minimal {id, name}[] list, gated on announcements:write instead of groups:read — lets any admin who can create a GROUP announcement populate the group picker without also needing group-management access. Registered before :id so it isn’t swallowed as a param. |
| GET | /groups/:id |
Get one group with memberCount |
| POST | /groups |
Create a group (name, optional description) |
| PATCH | /groups/:id |
Rename / update description |
| DELETE | /groups/:id |
Delete a group (cascades to its group_members rows) |
| GET | /groups/:id/members |
Paginated roster (page, limit; can grow large, mirrors the Workers-by-Department policy) — leftJoins the member relation so phone-only rows are included, not just real members |
| POST | /groups/:id/members |
Add a single real member (memberId) |
| POST | /groups/:id/members/bulk-add |
Add multiple real members at once (memberIds: string[]); returns {added, skipped} — duplicates are skipped, not errored |
| POST | /groups/:id/members/phone |
Add phone-only entries directly. Body: { entries: { phoneNumber, label? }[] }; numbers normalize to E.164 using CURRENCY_LOCALE; any invalid entry rejects the batch with 400 naming the number ("<number>" is not valid. <invalidPhoneMessage>). Returns {added, skipped} — duplicate normalized phone numbers within the group are skipped |
| POST | /groups/:id/members/first-timers |
Bulk-import every FirstTimer captured within a date range as phone-only entries (label = their name). Body: { dateFrom, dateTo } (ISO 8601); returns {added, skipped, invalid} — first-timers whose stored phone doesn’t normalize are counted in invalid and skipped, never failing the import |
| DELETE | /groups/:id/members/:memberId |
Remove a single real member by member id (kept for backward compatibility — cannot address phone-only rows, which have no member id) |
| POST | /groups/:id/members/bulk-remove |
Remove multiple real members at once by member id (memberIds: string[]); returns {removed} |
| DELETE | /groups/:id/entries/:entryId |
Remove a single roster entry by its own GroupMember row id — works for both real members and phone-only entries |
| POST | /groups/:id/entries/bulk-remove |
Remove multiple roster entries at once by row id (entryIds: string[]); returns {removed} |
| DELETE | /groups/:id/entries |
Remove every entry (members and phone-only) from the contact list, keeping the group; returns {removed}. Backs the admin roster’s “Select all N in this list” |
Resolving a group’s phone numbers for SMS (AnnouncementService.resolveGroupPhoneNumbers, used by both regular
announcement sendViaSms and the dedicated SMS-only broadcast): unions two sources — active Members in the group
with a phone number on file (via GroupService.getMemberIdsForGroup, then a direct Member query for the
active/phone-on-file filter) and raw phone-only entries (GroupService.getPhoneOnlyNumbersForGroup) — deduped
via Set. Phone-only entries have no “active” concept; they’re included as-is.
Indexes (migration AddGroupsModule): group_members(group_id) and group_members(member_id) back the roster listing and the group-audience membership check used by the announcement feed’s EXISTS subquery; announcements(group_id) backs the same feed query.
SMS Module
Provider-agnostic SMS sending — pure BYOK, no platform-default account and no prepaid credit balance. A tenant must configure and activate their own SMS provider (Communication Providers below) before they can send at all; there is no fallback. This is deliberate: a platform-run wallet billed in Naira only ever made sense for Nigerian tenants — BYOK lets a tenant in any country pick whichever SMS vendor actually serves them and pay that vendor directly.
Provider abstraction (src/sms/interface/sms-provider.interface.ts):
type SmsProviderCredentials = Record<string, string>; // e.g. Termii's { apiKey, senderId }, Twilio's { accountSid, authToken, fromNumber }
interface ISmsProvider {
readonly maxRecipientsPerRequest: number; // Termii: 100 (true bulk endpoint); Twilio: 20 (concurrency cap, no bulk endpoint)
send(to: string[], message: string, encoding: 'plain' | 'unicode', credentials: SmsProviderCredentials): Promise<{ messageId: string; status: string }>;
getBalance(credentials: SmsProviderCredentials): Promise<{ balance: number; currency: string }>;
getMessageHistory(credentials: SmsProviderCredentials): Promise<SmsLogEntry[]>;
}
SmsProviderRegistryService (src/sms/service/sms-provider-registry.service.ts, same shape as billing’s
PaymentProviderRegistryService) holds every registered vendor simultaneously — termii → TermiiSmsProvider,
twilio → TwilioSmsProvider — and SmsService resolves which one to use per call from the tenant’s active
TenantCommunicationProviderConfig.providerId, never a hardcoded class. Adding a vendor is a new ISmsProvider
class, a line in the registry, and a communication_providers catalog row — no other call site changes.
SmsService:
calculateSegments(message)— determines encoding and segment count for billing purposes. A message is encodedplain(GSM-7, 160 chars/segment) unless it contains a non-ASCII character or one of the characters Termii documents as forcing UCS-2/unicode encoding even though they’re otherwise ordinary ASCII punctuation:; ^ { } \ [ ~ ] | € ' "— in which case it’s encodedunicode(70 chars/segment). Returns{ segments, encoding, characterCount }.send(to, message, context?)— normalizes recipients to E.164 using the region fromCURRENCY_LOCALE(defaulten-NG); invalid recipients are recorded as failed and are never sent. It resolves config once (SmsCredentialResolverService.resolveConfig()); if the tenant has none configured, throws403 SMS_PROVIDER_NOT_CONFIGUREDbefore attempting anything. It then saves onePENDINGSmsDeliveryLogper recipient with provider and source metadata and enforces the 08:00–19:50 send window inCHURCH_TIMEZONE(defaultAfrica/Lagos) before contacting the provider. An out-of-window attempt is markedFAILEDin the local log and included in the returned outcome. It batchestointo groups of that provider’s ownmaxRecipientsPerRequest. Each batch updates its recipient rows toACCEPTEDorFAILED; provider request errors are returned as{ acceptedCount, failedCount, failures }rather than thrown, allowing the outer tenant transaction to commit diagnostics. Callers surface or log those failures.contextlinks announcement, broadcast, and reminder sends to their source.getLogs()/getBalance()— same resolve-or-403 pattern.getBalance()delegates to the tenant’s provider;getLogs()merges active provider history with local dispatch records and retains local rows if provider history is unavailable. A tenant sees their own vendor’s data, never the platform’s.
Message history (TermiiSmsProvider.getMessageHistory): calls Termii’s GET /api/sms/inbox?api_key=...
(Termii’s documented outbound message-history endpoint; without message_id it returns all account reports) and maps its
raw field names (receiver, message, status, sms_type, message_id, created_at, sender?) to the
provider-agnostic SmsLogEntry shape (recipient, message, status, type, messageId, sentAt, sender?,
provider? — the last set by SmsService.getLogs(), not by the provider class itself).
A non-array response body is treated as empty rather than thrown. SmsService.getLogs() joins provider history to up
to 500 recent local recipient records by (providerMessageId, recipient) and appends unmatched local rows so rejected
requests with no provider message ID remain visible. Log entries expose dispatchStatus, errorMessage, sourceType,
sourceId, sourceLabel, and trackingId; announcement rows can therefore be traced back to their announcement.
TwilioSmsProvider has no native bulk-send
endpoint, so it issues one POST per recipient (Promise.all, capped by maxRecipientsPerRequest) and joins the
returned sids with a comma for messageId.
TermiiSmsProvider uses the configured route credential (generic or dnd), defaulting to generic for existing
and new configurations. Generic is Termii’s promotional route; DND is for transactional/critical messages and must
be enabled by Termii for the workspace. The communication-provider summary returns only the non-secret smsRoute
field so the admin can preserve it while editing credentials. A 422 Route not configured response is explained in
the admin error. Recipients are normalized to E.164 before dispatch, then Termii receives its documented digits-only
international form (no leading +). Successfully Sent means Termii accepted the request; only a later Delivered
provider status confirms delivery to the handset.
Termii documents a 20:00–08:00 restriction for generic-route SMS to MTN; Discuva’s stricter 08:00–19:50 window
applies to all providers and routes.
Routes prefix: /admin/sms (AdminGuard)
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /admin/sms/balance |
SMS_READ | Returns { balance, currency } from the tenant’s active provider — 403 SMS_PROVIDER_NOT_CONFIGURED if none is active |
| POST | /admin/sms/segment-count |
SMS_READ | Body { message } — returns { segments, encoding, characterCount } without sending anything |
| GET | /admin/sms/logs |
SMS_READ | Merged provider history and up to 500 local dispatch rows with send origin, provider IDs, dispatch status, and failure details; frontend filters/paginates the response client-side |
Local tracking table: tenant migration CreateSmsDeliveryLogs creates sms_delivery_logs. It stores each recipient,
message, active provider, provider message ID/status, local dispatch state, provider error, source type/ID/label, and
creation time. Provider failures and out-of-window attempts are retained even if Termii returns no message ID.
Env vars: TERMII_BASE_URL (default https://api.ng.termii.com) — Termii’s API host is infrastructure, not a
secret, so it stays env-driven even under pure BYOK; every tenant’s Termii account (BYOK) talks to the same host.
No platform-default credentials exist for any SMS vendor. See Environment Variables.
Communication Providers (Tenant Self-Service BYOK)
Tenant-facing counterpart to Platform Admin’s read-only/catalog-only communication-provider surface — this is what
lets a church admin actually set their own SMS/email provider credentials (docs/MULTI_TENANT_MIGRATION.md
§Phase 6c’s deferred write side, now built). src/communication-provider/.
Encryption (EncryptionService, src/utility/service/encryption.service.ts): AES-256-GCM, keyed by
CREDENTIALS_ENCRYPTION_KEY (hashed via SHA-256 to a real 32-byte key — same min(32)-chars convention as
JWT_SECRET, no fixed hex/base64 format required on the operator). Each encrypted value is a self-contained
iv:authTag:ciphertext string (all base64) — nothing else needs to be stored alongside it to decrypt later.
encryptFields/decryptFields apply this to every value in a flat credentials object, keeping field names
(apiKey, senderId, etc.) intact and legible in the stored JSONB while no individual value is ever plaintext.
Rotating this key makes every previously-encrypted credential unreadable — there is no re-encryption tooling.
Credential resolution (SmsCredentialResolverService): pure BYOK, no wallet. resolveConfig() looks up the
current tenant’s active TenantCommunicationProviderConfig for the sms channel (cached 300s per
(tenantId, channel), invalidated immediately on write), decrypts it, and returns { providerId, credentials } —
or undefined if the tenant has no active SMS provider configured, which SmsService treats as “can’t send”
(403 SMS_PROVIDER_NOT_CONFIGURED), never as “use a default.”
Email credential resolution (EmailCredentialResolverService): same shape as the SMS resolver, email channel
— resolveConfig() returns { providerId, credentials, senderIdentity } (or undefined for “use platform
default”), cached under the same communication-provider-config:{tenantId}:{channel} key pattern (so
TenantCommunicationProviderService’s existing invalidation already covers this without any changes). No wallet —
email at church-scale volumes is a rounding error on any provider’s free tier, so there’s no cost to meter
(docs/MULTI_TENANT_MIGRATION.md §4.12). providerId matters here in a way it doesn’t for SMS: each provider has an
incompatible credential shape ({user, password[, host, port, secure]} for gmail/smtp, {apiKey} for
resend/sendgrid, {apiKey, domain} for mailgun), so EmailProcessor has to know which concrete
IEmailProvider to hand the decrypted credentials to, not just that BYOK credentials exist. senderIdentity doubles
as the email “from” address for a BYOK tenant (falls back to the platform’s EMAIL_FROM/EMAIL_USER when unset).
Email providers (src/utility/email-provider/): five implemented IEmailProvider classes; only gmail,
resend, and sendgrid are seeded into the communication_providers catalog (smtp/mailgun exist in code but
aren’t tenant-selectable yet — add a catalog row to turn one on) —
providerId |
Class | Credential shape | Platform default env vars |
|---|---|---|---|
gmail |
GmailProvider |
{user, password[, host, port, secure]} |
EMAIL_HOST/EMAIL_PORT/EMAIL_SECURE/EMAIL_SERVICE/EMAIL_USER/EMAIL_PASSWORD |
smtp |
SmtpProvider |
{host, port?, secure?, user, password} |
none — BYOK-only, throws if called without credentials |
resend |
ResendProvider |
{apiKey} |
RESEND_API_KEY |
sendgrid |
SendGridProvider |
{apiKey} |
SENDGRID_API_KEY/SENDGRID_BASE_URL |
mailgun |
MailgunProvider |
{apiKey, domain} |
MAILGUN_API_KEY/MAILGUN_DOMAIN/MAILGUN_BASE_URL |
sendgrid is Twilio’s actual email product (SendGrid) — catalog name SendGrid (Twilio) — registered alongside
twilio (SMS) so a tenant who wants Twilio across both channels can, using each product’s own real credential shape
(they’re genuinely separately-credentialed even though one company owns both, so this is two catalog rows, not one
shared “Twilio” entry).
gmail’s BYOK credentials accept an optional host/port/secure override on top of user/password — this is
what actually lets a tenant route mail through a different domain (Outlook/Office365, Zoho, their own company mail
server) rather than being locked to the platform’s own SMTP settings; omitting them reuses the platform’s own
host/port/secure/service with just a different mailbox. smtp is for a tenant who wants to fully bring their own
server with no platform fallback at all. sendgrid/mailgun call their REST APIs directly via native fetch (no
SDK dependency) — SendGrid with Bearer auth, Mailgun with HTTP Basic auth and a FormData body; both throw a clean
500 if neither BYOK nor platform-default credentials are configured, rather than silently no-op-ing.
Routes prefix: /communication-providers (AdminGuard, tenant-scoped — deliberately not under /platform,
which is entirely excluded from TenantMiddleware)
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /communication-providers |
COMMUNICATION_PROVIDERS_READ | ?channel=sms|email (optional) — returns { catalog, ownConfigs }; ownConfigs never includes credentials |
| PUT | /communication-providers/:channel |
COMMUNICATION_PROVIDERS_WRITE | Body { providerId, senderIdentity?, credentials: Record<string,string> } — upserts this tenant’s config for that channel (always activating it), encrypting credentials before storage |
| PATCH | /communication-providers/:channel/:providerId |
COMMUNICATION_PROVIDERS_WRITE | Body { isActive } — enable/disable an already-configured provider without touching its stored credentials |
Only one active provider per channel: both PUT and PATCH (when activating) run inside a transaction that also
deactivates every other provider already active on that same channel for the tenant
(TenantCommunicationProviderService.deactivateSiblings) — SmsCredentialResolverService/EmailCredentialResolverService
each pick a single isActive = true row per channel, so allowing more than one active at a time would make that
pick arbitrary. Turning a provider off never touches its siblings.
Communication Providers: deactivation has real consequences (added 2026-08). A platform admin can activate/
deactivate a provider in the platform-wide catalog (PATCH /platform/communication-providers/:id,
PlatformCommunicationProviderService.setActive — see Platform Admin above). Initially this only flipped the
CommunicationProvider.isActive column with zero downstream effect anywhere — verified live at the time: neither
credential resolver checked it, the tenant-facing catalog endpoint didn’t filter on it, and a tenant already
configured against a since-deactivated provider kept sending through it exactly as before. Three changes closed
that gap:
TenantCommunicationProviderService.listProviders()excludes an inactive provider from the catalog a tenant can newly select — unless that tenant already has a config against it, in which case it stays visible (filtering it out entirely would make an already-configured provider’s row silently vanish fromdiscuva-admin’s page with no explanation, even though its encrypted credentials are still saved).SmsCredentialResolverService/EmailCredentialResolverServicenow also requireprovider.isActive = truein the same query that already checksconfig.isActive = trueandprovider.channel. A deactivated provider genuinely stops resolving for every tenant using it, not just new ones.PlatformCommunicationProviderService.setActive()invalidates the 300s resolved-credential cache immediately for every tenant with an active config against the provider (communicationProviderCacheKey— extracted as a shared utility,src/communication-provider/utility/communication-provider-cache-key.ts, since four separate places needed the identical cache-key string and three of them were computing it independently before this), rather than leaving affected tenants to keep working for up to 5 more minutes. It also emails those same tenants’ admins —TenantBroadcastService.notifyTenants()(see “Tenant Broadcasts” under Platform Admin above), deliberately targeted at only the tenants actually using this provider, not a platform-wide broadcast — explaining the channel is disrupted (deactivating) or restored (reactivating). A tenant’s ownTenantCommunicationProviderConfigrow is never touched by any of this — same “don’t retroactively delete something already configured” posturesuspendTenantuses for a tenant’s own data.
Env vars: CREDENTIALS_ENCRYPTION_KEY (required, min(32) chars) — see Environment Variables.
Email BYOK send path (EmailProcessor.handleSend, src/utility/processor/email.processor.ts): unlike SMS,
which resolves credentials synchronously within the original request, an email send runs inside a Bull job — tenant
context isn’t ambient there, so handleSend wraps its entire body in runInTenantContext() (previously only
onCompleted/onFailed did this, purely to log) before calling EmailCredentialResolverService.resolveConfig().
Resolves to the concrete IEmailProvider matching the tenant’s providerId if BYOK-configured (falling back to
GmailProvider for gmail or any unrecognized id), otherwise the platform’s constructor-injected
EMAIL_PROVIDER_TOKEN default — source is 'tenant' in the former case, 'platform_default' in the latter.
Which provider/source actually handled a given send is carried back via Bull’s job-return-value convention
(job.returnvalue) so onCompleted logs the real provider/source to EmailLog.provider/EmailLog.source, not
just the platform default — that can differ per send once BYOK is in play. Since handleSend also persists the same
resolved provider/source onto job.data via job.update() before attempting the send, onFailed (which has no
return value to read, since a thrown send means handleSend never reaches its return) can log the actual
provider/source that failed instead of guessing the platform default.
Announcement integration: see “Optional SMS delivery” under Announcements Module — sending SMS on an
announcement requires the SMS_SEND permission (distinct from SMS_READ, which only allows checking balance/cost).
Tenant Profile Self-Service (src/tenant/)
GET /tenant/info (@Public(), still goes through TenantMiddleware) returns branding for the current subdomain —
unchanged. PATCH /tenant/info (AdminGuard, new CHURCH_PROFILE_WRITE permission) is the tenant self-service
write side (docs/MULTI_TENANT_MIGRATION.md §Phase 6c’s deferred item, now built) — lets a church admin edit their
own name/logoUrl/tagline/address/supportEmail/pwaShortName/currency/timezone without going through
platform support, which was previously the only way to change any of it (PATCH /platform/tenants/:id,
platform-admin-only). Body (UpdateTenantProfileDto) is a partial — every field optional, only provided fields are
applied (Object.assign). Deliberately excludes subdomain, schemaName, clusterId, and isActive —
platform-controlled, not something a church admin can change about their own tenant.
pwaShortName (nullable, max 20 chars): the label a member sees under the home-screen icon after installing the
PWA (Android’s manifest short_name, iOS’s apple-mobile-web-app-title) — deliberately separate from name, which
is often too long (formal church names) to survive the ~10-13 characters that render before truncation on a real
home screen. Falls back to name itself when unset (still a real improvement over the platform’s own generic name,
which was the bug this field was added to fix) — see discuva-member’s app/manifest.ts and
context/tenant-context.tsx for where the fallback chain (pwaShortName ?? name) is actually consumed.
platform-admin/dto/update-tenant.dto.ts and PlatformTenantService’s TenantWithHealth/toHealthShape mirror this
field for parity, though no discuva-platform UI currently exposes editing it — self-service via discuva-admin’s
Church Profile page is the only intended write path today.
Logo upload (POST /tenant/logo, DELETE /tenant/logo, both CHURCH_PROFILE_WRITE): logoUrl on
PATCH /tenant/info only ever accepted an already-hosted URL — these two routes are the actual upload path, same
shape as MemberController’s POST members/me/photo (DynamicLimitedFileInterceptor, image-mimetype-only filter,
CloudinaryService.uploadBuffer into the church-logos folder) but its own limit —
PlatformSettingKey.MAX_LOGO_UPLOAD_MB (5MB default, platform-admin-configurable), not MAX_AVATAR_UPLOAD_MB —
since a logo is reused across more surfaces than a profile photo and needs more headroom. Tenant.logoPublicId
(new column) tracks
the Cloudinary asset id so a replace or removal can delete the previous asset — deletion always happens after the
new row is saved, so a failed re-upload never leaves a tenant with no logo. All three routes (PATCH /tenant/info,
POST /tenant/logo, DELETE /tenant/logo) return the same profile shape.
Mobile app appearance (tenant_asset_overrides): the member PWA (discuva-member) ships a bundled
default hero/backdrop image for every screen — KNOWN_ASSETS
(src/tenant/constants/known-assets.constant.ts) is the fixed catalog of what can be overridden (25 keys today,
e.g. login-backdrop, home-door-welcome, giving-backdrop, finance-backdrop), each mapped to the screen(s) it actually renders on
in the mobile app. A church can override any of these with its own image; anything left unset silently falls back
to the app’s own bundled default — that fallback resolution happens client-side in discuva-member, not
here. This backend only ever knows what’s been explicitly overridden.
GET /tenant/info’s response gained anassets: Record<assetKey, imageUrl>field — only overridden keys appear in it, never the full catalog and never a default. Bundled into the same call the member app already makes on startup rather than a second round trip.GET /tenant/info’s response includesphoneRegion(ISO 3166 alpha-2, e.g.NG) — the region the API uses to parse local-format phone numbers (fromCURRENCY_LOCALE), so client phone pickers default to the same country the server validates against.GET /tenant/info’s response also gained a plainsubdomain: stringfield — not sensitive (already visible in every discuva-member URL, and the admin types it in at login), added specifically so discuva-admin has a client-side “which tenant am I” signal for its Games presentation-screen fix (see Games Module): that route is deliberately public/unauthenticated (so it can run unattended on a projector) and discuva-admin has no per-tenant subdomain of its own to resolve tenant from the way discuva-member does (single shared host in production; tenant normally comes from the JWT instead), so a public route there has nothing to identify its tenant with unless it’s carried explicitly.GET /tenant/assets/catalog(AdminGuard,CHURCH_PROFILE_WRITE) — the fixedKNOWN_ASSETSlist with labels/descriptions, for the admin appearance-settings page to render without duplicating the catalog client-side.POST /tenant/assets/:key(AdminGuard,CHURCH_PROFILE_WRITE) — upload/replace the override for one asset key. Same upload shape as logo upload (multer,PlatformSettingKey.MAX_LOGO_UPLOAD_MBlimit — 5MB default, image-mimetype-only,CloudinaryService.uploadBuffer, into thetenant-assetsCloudinary folder this time).:keyis validated againstKNOWN_ASSETSinTenantAssetService, not at the DB level, so the catalog can grow without a migration. Same “new asset saved before the old one is deleted” ordering as logo upload.DELETE /tenant/assets/:key(AdminGuard,CHURCH_PROFILE_WRITE) — removes the override row and the Cloudinary asset, reverting that screen to the app’s bundled default. A no-op (still200, still deletes nothing) if no override existed for that key.
discuva-member: public-link routes vs. “the app” (components/pwa/standalone-gate.tsx). PUBLIC_LINK_ROUTE_PREFIXES
(/forms/public/, /classes/guest/, /p/) is the fixed list of routes explicitly designed to be reachable by
anyone with the link — no account, no installed app (a QR-scanned public form, a guest class portal, a public
page). StandaloneGate (mounted above the whole app in app/layout.tsx) already exempts these from the
“install this app first” wall for exactly that reason. UpdateBanner (same layout, “a new version of the app
is ready”) had the identical gap and no exemption at all — reported live: it makes no sense to someone who just
opened a shared link and was never “in the app” to begin with. Fixed by exporting the same prefix check
(isPublicLinkRoute) from standalone-gate.tsx and reusing it in update-banner.tsx, rather than maintaining
a second list that could quietly drift out of sync with the first.
TenantAssetOverride lives in public (tenant_asset_overrides, FK to tenants.id ON DELETE CASCADE), not a
per-tenant schema — this is the same category of data as Tenant.logoUrl (self-service branding a church sets
once and rarely touches), not operational data needing schema isolation. One row per (tenant, assetKey),
enforced with a unique constraint.
The admin-side crop tool (discuva-admin’s Appearance settings page) guides toward each asset’s actual render
ratio before upload, but nothing here enforces it server-side — every one of these images renders with
object-cover inside a fixed-size container in the member app, so a mismatched upload crops awkwardly rather than
breaking layout.
Billing & Checkout (src/billing/)
Tenant self-service surface for the plan/subscription infrastructure described in
docs/MULTI_TENANT_MIGRATION.md §4.11/§9 Phase 3 — view current plan/subscription status and initiate a Paystack or
Flutterwave checkout to upgrade the plan. PlanGuard itself doesn’t depend on any of this working — a tenant can
always be moved onto Pro manually via the platform-admin escape hatch (PATCH /platform/tenants/:id/plan); this
module is what lets a tenant do it themselves, and pay for it. (SMS billing lives entirely outside this module now —
see SMS Module above for why.)
Three payment providers, registered simultaneously (PaymentProviderRegistryService) — unlike email, where one
platform-default concrete class is chosen once at boot (SMS has no platform default at all, pure BYOK — see SMS
Module above), PaystackPaymentProvider, FlutterwavePaymentProvider, and KoraPaymentProvider are always
available; a checkout call picks one by name (?provider=paystack/flutterwave/kora in the request body),
defaulting to DEFAULT_PAYMENT_PROVIDER when unspecified. All three are platform-wide credentials, not tenant
BYOK — unlike SMS/email/YouTube, these charges pay the platform (plan upgrades), so the merchant keys have to be
the platform’s own, never a tenant’s. This is the platform-billing counterpart to the tenant-facing, fully-BYOK
giving/tithe system (src/giving-checkout/, which also supports Kora — KoraGivingProvider — plus Stripe); the two
systems share no config or code, only the same proven Korapay request/webhook-signing shape.
Plan.currency is validated against SUPPORTED_BILLING_CURRENCIES (src/billing/constant/supported-currencies.constant.ts), currently ['NGN', 'USD'] only. Nothing in PaymentProviderRegistryService or any of the three provider implementations checks that Discuva’s own merchant account for the chosen provider can actually settle in a plan’s currency — a plan created with an unsupported currency would only fail at charge time, at the provider, not at plan-creation time. CreatePlanDto/UpdatePlanDto enforce this whitelist so a currency can’t be picked (via discuva-platform’s Plan form or a direct API call) without first confirming it with each active provider’s Discuva-owned account and widening the constant.
Multi-currency, multi-interval tiers (Plan.tierKey, Plan.billingInterval): each Plan row is still exactly one immutable priced offering in one currency and one billing interval — id remains the real billing identity (Subscription.planId, Plan.billingProviderPriceId all key off it, untouched by anything below). tierKey is a separate, purely-display grouping key that lets multiple rows represent the same conceptual tier across currency and/or interval — e.g. pro (NGN, monthly), pro-usd (USD, monthly), pro-annual (NGN, annual) and pro-usd-annual (USD, annual) all share tierKey: 'pro', four independent rows, each a real, deliberately-priced offering (never a currency conversion or a computed 12x-minus-discount of another). PlanGuard/PlanFeatureResolverService/checkout are entirely unaffected — they resolve via Subscription.planId → Plan, never tierKey. tierKey and billingInterval ('monthly' | 'annual', BillingInterval enum) are both required on POST /platform/plans and optional on PATCH /platform/plans/:id. Safeguard: PlatformPlanService.updatePlan() rejects (400) a currency or billingInterval change once Plan.billingProviderPriceId is already set, since the cached provider-side price/interval object would silently keep charging at the old currency/cadence — create a new plan variant row instead of editing an existing plan’s currency or interval.
Interval-aware period extension: CheckoutService.applyChargeSucceeded() looks up the charged Plan’s billingInterval and extends Subscription.currentPeriodEnd by SUBSCRIPTION_PERIOD_DAYS (monthly, default 30) or ANNUAL_SUBSCRIPTION_PERIOD_DAYS (annual, default 365) accordingly — both a fresh checkout and (for Paystack, see below) a provider’s own renewal charge.succeeded go through this same path, so an annual charge genuinely grants ~365 days, not 30. Only Paystack’s lazily-created provider-side Plan object is told this interval at all (interval: 'annually' for an annual Plan, Paystack’s own documented value, mapped from our BillingInterval.ANNUAL) — see the Flutterwave/Kora capability-gap note below for why Flutterwave never receives one. PlatformAnalyticsService.mrrByCurrency() normalizes an annual subscriber’s price to a monthly-equivalent (÷12) before summing, so “MRR” stays actually monthly rather than overstating annual subscribers ~12x.
Currency unit mismatch between the providers, handled internally: Paystack’s Initialize Transaction takes
amount in the currency’s smallest unit (kobo for NGN) — matches this codebase’s existing priceCents/amountCents
convention, no conversion needed. Flutterwave’s Standard Payment and Korapay’s Initialize Charge both take the
major unit (naira) — every amount is divided by 100 before being sent and multiplied back where relevant. Only
Paystack lazily creates (and persists onto Plan.billingProviderPriceId) a matching provider-side plan object the
first time a planId is checked out against — Flutterwave and Kora never do, see below.
Neither Kora nor Flutterwave has a working recurring-subscription mechanism — a real capability gap, documented
rather than papered over. Korapay has no confirmed subscription/plan product at all; KoraPaymentProvider never
claimed one. Flutterwave does have a documented payment-plans + payment_plan API and this codebase originally
used it the same way Paystack uses its plan object — but a real Paystack-style sandbox test exposed that it
doesn’t reliably work: a successful Flutterwave subscription checkout came back with paymentPlan: null on its
charge.completed webhook. The channel the customer paid with was USSD ("event.type": "USSD_TRANSACTION" in that
payload) — only a card payment is actually re-chargeable later, and Flutterwave’s hosted checkout offers whichever
channels are enabled on the account with no way from this codebase to restrict a subscription checkout to card
only. Rather than depend on the customer happening to pick a channel that supports it, FlutterwavePaymentProvider
was changed to match KoraPaymentProvider exactly: createSubscriptionCheckout is a single charge for the plan’s
price (no payment_plan attached), not an auto-renewing subscription, and Plan.billingProviderPriceId is never
set by either provider. Concretely: a tenant on Paystack may be silently re-charged by Paystack’s own recurring
engine when their period ends (see SubscriptionLapseScheduler below); a tenant on Flutterwave or Kora never will
be — they always fall through to the normal failed-renewal flow (PAST_DUE email → grace period → downgrade) and
must complete a fresh checkout to renew. Both FlutterwavePaymentProvider.cancelSubscription and
KoraPaymentProvider.cancelSubscription are correspondingly documented no-ops (nothing server-side to cancel).
Kora’s refund() throws rather than calling an unverified endpoint — Korapay’s real refund API shape hasn’t been
confirmed against sandbox behavior the way /charges/initialize and its webhook signing have (proven first in
KoraGivingProvider); Flutterwave’s refund endpoint (unrelated to the payment-plan gap above) has been verified.
Don’t set DEFAULT_PAYMENT_PROVIDER to kora or flutterwave without accounting for the lack of real
auto-renewal.
BillingCheckoutSession (public.billing_checkout_sessions) is recorded at checkout-initiation time, primary-
keyed by the provider’s own reference (Paystack reference / Flutterwave tx_ref) — this is the only thing a
webhook payload is ever trusted for identity/amount against. CheckoutService.handleWebhookEvent() looks up this
row by the reference the webhook echoes back; a reference with no matching pending row (unknown, already
processed, or forged) is a safe no-op, never an error that could imply something was charged. One intent today:
subscription (activates a period on Subscription — see SUBSCRIPTION_PERIOD_DAYS/ANNUAL_SUBSCRIPTION_PERIOD_DAYS — not true
provider-driven recurring-billing reconciliation, deferred pending live sandbox testing). A wallet_topup intent
existed pre-BYOK (funded a prepaid SmsWallet debited per SMS sent) — removed along with the wallet itself once SMS
went pure BYOK (§ SMS Module); BillingCheckoutType only has SUBSCRIPTION now.
Self-serve cancel/downgrade (CheckoutService.cancelSubscription): the tenant-facing counterpart to the
platform-admin escape hatch. Still within a paid period (currentPeriodEnd in the future): sets
Subscription.cancelAtPeriodEnd = true and the tenant keeps their plan’s features until that date —
SubscriptionLapseScheduler (below) completes the downgrade once it passes, rather than yanking access from a
period they already paid for. No active period left: downgrades immediately. Best-effort calls the provider’s own
cancelSubscription() first (via billingProviderSubscriptionId), but a provider API failure never blocks the
local downgrade — the tenant’s stated intent to stop wins regardless. Throws 400 if the tenant has no paid
subscription, or if the plan is sponsored by a parent tenant (see Branch Hierarchy below — a sponsored plan isn’t
the branch’s own to cancel).
Failed-renewal safety net (SubscriptionLapseScheduler, daily 04:00, distributed-lock guarded): finds every
ACTIVE subscription whose currentPeriodEnd has passed with no new charge.succeeded webhook extending it.
Backed by a composite (status, current_period_end) index (idx_subscriptions_status_current_period_end,
AddSubscriptionStatusPeriodEndIndex migration) — the query is WHERE status = 'active' AND current_period_end < now(), and since ACTIVE is presumably the majority status platform-wide, a single-column status index (the old
idx_subscriptions_status, dropped in the same migration as redundant — the composite’s leftmost prefix already
covers a status-only filter) barely narrowed the scan on its own.
cancelAtPeriodEnd = true (a voluntary cancellation reaching its natural end) downgrades immediately, no drama. Any
other lapse is treated as a failed renewal: flips status to PAST_DUE (the “queryable payment-status field” the
frontend can key a banner off — SubscriptionStatus.PAST_DUE existed as an enum value long before anything actually
set it), emails the tenant’s oldest active admin, and gives a GRACE_PERIOD_DAYS (7) window before finally
downgrading to Free. Known limitation, documented rather than silently accepted: Subscription.billingProviderSubscriptionId
capture is wired up for Paystack (subscription.create, verified against a real sandbox payload, see below) — a
Paystack tenant whose subscription is canceled from Paystack’s own hosted portal is now recognized as canceled
immediately rather than only once they lapse here. This doesn’t apply to Flutterwave or Kora at all, but not
because anything is unwired — neither provider ever creates a real server-side subscription in the first place
(see the capability-gap note above), so there’s no provider-side cancellation event to miss; a Flutterwave/Kora
tenant’s renewal is always self-serve, and this scheduler’s PAST_DUE → grace period → downgrade flow is the
expected path for them, not a gap. “Retrying” a failing card
is the provider’s own responsibility (both Paystack and Flutterwave retry several times before giving up, well
within the 7-day window) — this scheduler only reflects local state, it never re-attempts a charge itself.
Branch plan sponsorship (Subscription.sponsoredByTenantId): set when a branch’s plan was comped by its parent
at invite time rather than paid independently — see Branch Hierarchy below. Deliberately excluded from
PlatformAnalyticsService’s MRR calculation (no real money backs it) and blocks the branch’s own admin from
self-cancelling it (that’s the parent’s call, via the invite/hierarchy relationship, not a POST /billing/cancel
on a plan they don’t actually pay for).
Refunds (platform-admin only, not tenant-facing): IPaymentProvider.refund(providerReference, amountCents?) —
Paystack refunds by transaction reference directly; Flutterwave requires resolving the reference to its own numeric
transaction id first (GET /transactions?tx_ref=), handled internally. Kora’s refund() throws unconditionally —
not implemented against a verified endpoint (see above) — so refundCheckoutSession() on a Kora-paid session fails
loudly rather than silently no-op’ing; refund a Kora charge directly in the Korapay dashboard instead until this is
built. CheckoutService.refundCheckoutSession()
only allows refunding a completed session, marks it BillingCheckoutStatus.REFUNDED, and deliberately does
not automatically downgrade a plan — that requires a product decision (does downgrading strand data created on the
paid tier?) this pass doesn’t take on. A platform admin issuing a refund is expected to also apply the tenant-facing
consequence manually via the existing escape hatch if warranted.
Payment Providers: deactivation has real consequences (added 2026-08, same pass as Communication/Giving
Providers’ equivalents). payment_providers (PlatformPaymentProvider) gives platform admins the same
list/deactivate capability over paystack/flutterwave/kora that already existed for communication and giving
providers — but the blast radius is meaningfully narrower here, because unlike those two there’s no per-tenant BYOK
config table: every tenant shares the platform’s own provider credentials, so there’s nothing to filter out of a
tenant-facing catalog and nothing per-tenant to cache-invalidate.
PaymentProviderRegistryService.get()— used by webhook handling (handleWebhookEvent), self-serve cancel (cancelSubscription), and refunds (refundCheckoutSession) — deliberately never checks the DBisActiveflag. An already-charged or already-subscribed tenant’s in-flight lifecycle must keep working regardless of a later deactivation; rejecting a webhook for an already-completed charge would take the tenant’s money without crediting their subscription, the same reasoningGivingCheckoutService.handleWebhookalready established for tithe/giving webhooks.PaymentProviderRegistryService.assertActive()— a new, separate method, used only byinitiateSubscriptionCheckout()— resolves the same provider name then throws400if itspayment_providersrow is deactivated. This is the only place deactivation is actually enforced: starting a new subscription checkout against a deactivated provider.PlatformPaymentProviderService.setActive()looks up everySubscriptioncurrently on that provider (any status exceptCANCELED) and sends a targetedTenantBroadcastService.notifyTenants()email — worded accurately rather than reusing the Communication/Giving copy verbatim, since an existing subscriber’s recurring renewal is genuinely unaffected (it flows through the webhook path above, which never checksisActive); only starting a new checkout with that provider is blocked until it’s restored.- No
registerProvider()— unlikeCommunicationProvider/GivingProvider, paystack/flutterwave/kora are hard-codedIPaymentProviderclasses wired intoBillingModule(PaystackPaymentProvideretc.), not arbitrary BYOK entries a platform admin can add by id/name alone. A fourth vendor needs its own provider class written and registered inPaymentProviderRegistryServicefirst, same as it always has — the DB row is just bookkeeping for that vendor’s on/off state, not a way to add one.
Routes (AdminGuard, tenant-scoped, unless noted):
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /billing/summary |
BILLING_READ | { planId, planName, subscriptionStatus, currentPeriodEnd, cancelAtPeriodEnd, sponsoredByParent } |
| GET | /billing/providers |
BILLING_READ | Payment providers a church can pick at plan checkout: [{ id, name }] — active, registered and configured only |
| GET | /billing/plans |
BILLING_READ | Full plan catalog ([{ id, name, tierKey, priceCents, currency, features }]), ordered by price ascending — every currency variant of every tier as its own row; the frontend groups by tierKey itself. The only tenant-accessible plan list; GET /platform/plans is platform-admin-only |
| GET | /billing/public/plans |
None — @Public() (bypasses the global JwtAuthGuard) and TenantMiddleware-excluded |
Tier-grouped catalog for discuva-web (no tenant/admin context at all): [{ tierKey, name, features, featureLimits, variants: [{ planId, currency, priceCents, billingInterval }] }], variants and tiers sorted by price ascending |
| POST | /billing/checkout/subscribe |
BILLING_WRITE | Body { planId, provider?, successUrl, cancelUrl } — returns { checkoutUrl } to redirect the admin to; 400 if the named (or default) provider is deactivated — see “Payment Providers: deactivation has real consequences” above |
| POST | /billing/cancel |
BILLING_WRITE | No body — cancels immediately or at period end depending on currentPeriodEnd; 400 if no paid/cancelable subscription |
| POST | /webhooks/billing |
No guard — provider webhook | @Public(), dispatches to Paystack or Flutterwave by which of their two signature headers is present (x-paystack-signature HMAC-SHA512 vs verif-hash shared-secret string compare) |
| GET | /platform/tenants/:id/billing-sessions |
Platform admin | This tenant’s checkout session history, newest first |
| POST | /platform/billing-sessions/:sessionId/refund |
Platform admin | Body { amountCents? } — omitted means a full refund |
| GET | /platform/payment-providers |
Platform admin (BILLING_READ) |
[{ id, name, isActive }], ordered by name |
| PATCH | /platform/payment-providers/:id |
Platform admin (BILLING_WRITE) |
{ isActive } — activate/deactivate. See “Payment Providers: deactivation has real consequences” above. |
Monnify (Moniepoint) for platform billing (added 2026-09-30): MonnifyPaymentProvider, charging Discuva’s own
Monnify account (MONNIFY_API_KEY, MONNIFY_SECRET_KEY, MONNIFY_CONTRACT_CODE; MK_TEST_ keys use Monnify’s
sandbox). Shares MonnifyApi (src/utility/monnify/monnify-api.ts — sign-in token cache, init-transaction,
signature check) with the giving provider. Same limits as Korapay: no recurring-plan product, so a subscription is one
charge for the plan’s price and renews through the normal lapse/checkout flow; cancelSubscription is a no-op;
refund throws (refund in the Monnify dashboard). Webhooks arrive on the shared POST /v1/webhooks/billing route,
dispatched by the monnify-signature header. Only a PAID SUCCESSFUL_TRANSACTION activates a plan; PARTIALLY_PAID /
OVERPAID are logged and left pending for the platform team. Seeded inactive (root migration
AddMonnifyPlatformPaymentProvider) — set the keys, then switch it on in the platform portal.
Which providers churches see: GET /billing/providers (BILLING_READ) returns [{ id, name }] for providers
that are active in payment_providers, registered, and have their key set (PAYSTACK_SECRET_KEY,
FLUTTERWAVE_SECRET_KEY, KORA_SECRET_KEY, MONNIFY_API_KEY). The church admin’s billing page offers
Paystack/Flutterwave/Monnify from that list (Korapay is still not offered there); if the endpoint is missing it falls
back to Paystack and Flutterwave.
Env vars: PAYSTACK_SECRET_KEY, PAYSTACK_BASE_URL, FLUTTERWAVE_SECRET_KEY, FLUTTERWAVE_SECRET_HASH,
FLUTTERWAVE_BASE_URL, MONNIFY_API_KEY, MONNIFY_SECRET_KEY, MONNIFY_CONTRACT_CODE, DEFAULT_PAYMENT_PROVIDER, SUBSCRIPTION_PERIOD_DAYS (default
30, monthly-plan renewal period), ANNUAL_SUBSCRIPTION_PERIOD_DAYS (default 365, annual-plan renewal
period — both read by CheckoutService.applyChargeSucceeded(), keyed by the charged plan’s billingInterval),
GRACE_PERIOD_DAYS (default 7, SubscriptionLapseScheduler’s
PAST_DUE window before downgrading to Free) — see Environment Variables.
Paystack subscription.create handling (added and verified against a real sandbox payload):
CheckoutService.applySubscriptionCreated(), triggered by PaymentEventType.subscription.created, fires once
right after the first successful charge on a subscription-linked transaction — confirmed live that charge.success
itself never carries a subscription identifier, only this separate event does (data.subscription_code). Matched
to a tenant via data.customer.metadata.tenantId (the same metadata attached at checkout-initiation time), not a
checkout reference, since a freshly-created provider subscription has none of its own. Populates
Subscription.billingProviderSubscriptionId (closing the gap applySubscriptionCanceled needed — see above) and,
when the payload includes one, sets currentPeriodEnd directly from the provider’s own next_payment_date rather
than our SUBSCRIPTION_PERIOD_DAYS/ANNUAL_SUBSCRIPTION_PERIOD_DAYS math, since that reflects the provider’s
actual billing clock rather than whenever we happened to receive a webhook. No Flutterwave/Kora equivalent, and
none is planned — neither provider ever creates a real server-side subscription (see the capability-gap note
above), so there’s no creation event to capture an id or a next-payment-date from; their currentPeriodEnd is
always purely SUBSCRIPTION_PERIOD_DAYS/ANNUAL_SUBSCRIPTION_PERIOD_DAYS math from checkout time, by design. True
full recurring-billing reconciliation (the provider’s own renewal events driving every subsequent period, not just
the first) is still not built even for Paystack — only the first charge’s subscription-creation metadata is
captured today.
Billing/plan settings UI in discuva-admin (/billing) is built — plan picker, cancel/downgrade, past-due banner,
plan-inheritance indicator for a sponsored branch. (SMS wallet top-up UI was removed along with the wallet itself —
SMS billing is now entirely the tenant’s own vendor relationship, outside this app.)
Plan feature gating (PlanGuard) — boolean gate plus an optional, per-route numeric cap: any route decorated
@RequiresPlan(PlanFeature.X) first checks Plan.features membership (boolean gate, cached under
plan-features:${tenantId} for 300s via the shared PlanFeatureResolverService) and throws
403 { code: 'PLAN_UPGRADE_REQUIRED' } if the feature isn’t included. If the feature is included, the plan has a
numeric limit configured for it (Plan.featureLimits, a jsonb map of capability key → max lifetime uses,
admin-editable via PATCH /platform/plans/:id), and the specific handler invoked also carries
@CountsTowardLimit(PlanFeature.X), PlanGuard does a read-only check (FeatureUsageService.getUsage) and 403s
if usage is already at the cap. This is deliberately narrower than the boolean gate above: @RequiresPlan sits at
class level and covers every route in a controller (list, read, poll, join…), while @CountsTowardLimit is
opt-in per method — only the one route meant to consume a use (typically create) carries it, e.g.
AdminGameController.create is the only Games route with @CountsTowardLimit(PlanFeature.GAMES); listing games,
polling a live session’s state, joining, answering, and viewing a leaderboard never touch the counter even when a
games limit is configured.
The actual increment happens in PlanLimitInterceptor (src/billing/interceptor/plan-limit.interceptor.ts,
registered globally via APP_INTERCEPTOR in billing.module.ts, a no-op unless the route carries
@CountsTowardLimit), after the handler succeeds — via RxJS tap() on the response — not before. A request
whose handler throws (validation error, 404, etc.) never reaches the tap(), so a failed create never spends a
use; only a request that actually completes does. The increment itself reuses FeatureUsageService.tryConsume
(backed by public.feature_usages, one row per (tenantId, feature), a single conditional
INSERT ... ON CONFLICT ... WHERE count < limit), fire-and-forget from the interceptor’s point of view — the
response has already been decided by the guard’s earlier read-only check. Splitting “check” (guard, before) from
“consume” (interceptor, after success) reopens a narrow race two truly concurrent creates could both pass the
pre-check before either increments; tryConsume’s own WHERE count < limit still caps the damage to at most one
extra unit of usage, an accepted tradeoff over the alternative of consuming on every request regardless of outcome.
Usage counts are lifetime and never reset by a plan change — upgrading past a cap and later downgrading back below
it still reflects prior usage rather than granting a fresh allowance.
Every toggleable module is also a plan-assignable capability, not just the original 12 PlanFeature values.
ModuleEnabledGuard (src/church-settings/guard/module-enabled.guard.ts) — the guard behind every
@RequiresModule('x') controller — checks two things in order: the tenant’s ChurchSetting on/off toggle
(unchanged), then whether x is included in the tenant’s plan’s features array (same PlanFeatureResolverService
lookup PlanGuard uses), 403ing with the identical PLAN_UPGRADE_REQUIRED shape if not. This makes moving any
module (originally Prayer, Evangelism, Training Classes, Tithe/Giving, Sunday School, Pastor Feedback, Fellowships,
Social Media, Children’s Church, Announcements, Follow-Up — the 11 that were previously free with no plan concept
at all) between Free and Pro a PATCH /platform/plans/:id data change from the discuva-platform Plans page, not a code
deploy — no new PlanFeature enum value, no new @RequiresPlan decorator, no migration. ALL_CAPABILITY_KEYS
(src/billing/constant/capability-keys.constant.ts) is the full set of strings a features/featureLimits entry
may validly be: the original PlanFeature values (finance/sms/audit/bulk_export have no KNOWN_MODULES
counterpart and stay purely plan-gated, no toggle) unioned with every KNOWN_MODULES key. GET /platform/capabilities
(PlatformCapabilityService) returns this same set labeled for the Plans page’s checkbox list, replacing what used
to be a hardcoded 12-entry array in plan-form-panel.tsx (which — notably — never included forms, fixed as a
side effect).
One key per module (fixed 2026-09-29): PlanFeature.SERMON, SERVICE_RATING and VOLUNTEER used to be sermon,
service_rating and volunteer while their modules used sermons, service_ratings and volunteering. Access
needed both keys, but the Plans page only listed the module keys, so platform admins couldn’t grant these to a plan,
and removing one from Pro only blocked the member side (the admin controllers check @RequiresPlan alone). The enum
now uses the module keys, and root migration UnifySermonRatingVolunteerPlanKeys renames the old keys in
plans.features, plans.feature_limits and tenants.module_overrides. PlanGuard also honours
Tenant.moduleOverrides now (false blocks, true grants, checked before plan membership) — the same precedence as
ModuleEnabledGuard — so a per-church override works on every route.
A one-time backfill migration (BackfillModuleCapabilityKeys) added the 11 previously-free module keys to both
free and pro plans’ features (preserving today’s access for every tenant — a platform admin removes a key
from free afterward to make it Pro-only) and 3 module-key spellings that don’t match their pre-existing
PlanFeature value (sermons/sermon, service_ratings/service_rating, volunteering/volunteer — these
three already carried both decorators with two different strings) to pro only, alongside the existing spelling —
a known, deliberately-left naming inconsistency, not something worth a tenant-data rename for now.
Tithe/Giving (tithe key — manual recording, BYOK payment-provider setup, and the member checkout flow) is
Pro-only (MoveTitheToProOnly migration, run right after the backfill above). Same treatment as sms: BYOK
means it costs Discuva nothing regardless of volume (money flows straight through the tenant’s own Paystack/
Flutterwave/Stripe/Kora account, TenantGivingProviderController), but it’s high enough business value that it’s
gated as a deliberate upgrade lever rather than left free on cost grounds. The remaining 10 free-from-launch
modules (Prayer, Evangelism, Training Classes, Sunday School, Pastor Feedback, Fellowships, Social Media,
Children’s Church, Announcements, Follow-Up) are unaffected.
Per-tenant manual override, independent of plan (Tenant.moduleOverrides). The plan-features mechanism above
answers “which tier includes this module,” a platform-wide default every tenant on that tier shares. It has no
answer for “let this one specific church test it regardless of their plan” or “pull access from this one tenant
without touching their plan or anyone else’s” — exactly the shape of control needed to roll a still-unstable module
(Social Media, initially) out to a hand-picked set of test tenants before it’s ready to sit in any plan’s default
features at all. moduleOverrides is a nullable jsonb map on Tenant, keyed by the same KNOWN_MODULES/
PlanFeature strings as Plan.features — { social_media: true } grants that module regardless of plan,
{ social_media: false } blocks it regardless of plan, a key simply absent (or the whole map null) means no
override — falls through to the plan check exactly as before this existed. ModuleEnabledGuard checks it between
the tenant’s own on/off toggle and the plan-features check: tenant toggle off still always wins (a church’s own
choice is never overridden), then override false blocks outright, override true grants outright, and only an
absent override falls through to features.includes(moduleKey). PlanGuard applies the same override precedence
(since 2026-09-29), so a module whose routes also carry @RequiresPlan — Sermons, Service Ratings, Volunteering, Forms,
etc. — honours the override on every route. setModuleOverride() still only accepts KNOWN_MODULES keys, so the
plan-only features (finance, sms, audit, bulk_export, notification_customization) can’t be overridden per
church from the Tenant edit UI.
PlanFeatureResolverService.resolve() — already shared by both guards, already caching per-tenant under
plan-features:${tenantId} — now also fetches the Tenant row and returns overrides alongside features/
featureLimits, so this costs no extra guard-level round trip or cache key. PlatformTenantService.setModuleOverride()
validates moduleKey against KNOWN_MODULES, merges into the existing map (clearing to null only once the
last override key is removed, never wiping a tenant’s other overrides), saves, and invalidates the same
plan-features:${tenantId} cache key changeTenantPlan/applyDiscount already do.
| Method | Route | Auth | Notes |
|---|---|---|---|
| PATCH | /platform/tenants/:id/module-overrides |
PlatformAdminGuard (TENANTS_WRITE) | {moduleKey: string, enabled: boolean | null} — null clears just that one key |
MakeSocialMediaOverrideOnly migration removed social_media from every plan’s features (it had been in
pro/pro-annual/pro-usd/pro-usd-annual since the original backfill, never in free) — Social Media is no
longer plan-included by default anywhere. It’s an opt-in, still-early-access module now gated entirely through the
Social Media Rollout control below.
Social Media Rollout — the one control surface (PlatformTenantService.setSocialMediaRollout/
getSocialMediaRollout). A platform admin doesn’t reason about Plan.features vs Tenant.moduleOverrides
separately for this module — they use a single toggle plus an optional searchable multi-select of churches, and
setSocialMediaRollout() decides which underlying mechanism to write:
- Disabled: strips
social_mediafrom every plan’sfeaturesand clears thesocial_mediakey from every tenant’smoduleOverrides. Nobody has access. - Enabled, empty selection (“everyone”): adds
social_mediato every plan’sfeatures(all tiers, not just Pro — a true “for all” regardless of plan) and clears every tenant’s override. Forward-looking: a tenant created next week is covered automatically via the plan check, same as any other plan-included module. - Enabled, specific selection: strips
social_mediafrom every plan’sfeatures(so it stays off by default) and setsmoduleOverrides.social_media = truefor exactly the selected tenants — clearing the key for any previously-selected tenant no longer in the list, so re-saving a shorter list actually revokes access rather than only ever adding to it.
The two mechanisms are kept mutually exclusive on write so there’s never a redundant or contradictory state (a
tenant with a true override while the plan already includes the module, or vice versa).
| Method | Route | Auth | Notes |
|---|---|---|---|
| GET | /platform/social-media/rollout |
PlatformAdminGuard (SOCIAL_MEDIA_APPS_READ) | {enabled: boolean, tenantIds: string[]} — derived live: enabled:true, tenantIds:[] if any plan includes social_media, else the list of tenants with a true override |
| PUT | /platform/social-media/rollout |
PlatformAdminGuard (SOCIAL_MEDIA_APPS_WRITE) | {enabled: boolean, tenantIds: string[]} — full replace, not incremental |
Frontend: a “Social Media Rollout” card on discuva-platform’s Social Media Apps page (RolloutPanel) — an
on/off switch plus a searchable multi-select of churches (chips + type-ahead), shown only when enabled. Replaces
the earlier per-tenant TenantDetailPanel “Force On/Off” buttons, which required visiting each church individually
and made “roll out to everyone” a two-step, easy-to-forget dance (remove from Plan.features, then Force On each
tenant by hand) — this is now one screen, one save. TenantDetailPanel’s “Social Media Access” field is now
read-only status text (resolved from the same moduleOverrides/plan data) with a link back to this page; the
generic PATCH /platform/tenants/:id/module-overrides endpoint and setModuleOverride() still exist underneath
and remain usable for any other KNOWN_MODULES key that later needs the same per-tenant-override treatment.
Pages Rollout — same mechanism, generalized. setSocialMediaRollout/getSocialMediaRollout were the only
implementation of this rollout lifecycle until Pages needed the identical “opt-in, still-early-access module with
no platform-admin control surface at all” treatment the Pages section above already flags (it shipped gated purely
through Tenant.moduleOverrides, with no dedicated rollout UI, requiring a platform admin to Force On each church
individually via the generic module-overrides endpoint). Rather than duplicate the whole all-vs-specific/
Plan.features-vs-moduleOverrides branch a second time, that logic moved into private
PlatformTenantService.setModuleRollout(moduleKey, dto)/getModuleRollout(moduleKey), with
setSocialMediaRollout/getSocialMediaRollout now one-line delegates (moduleKey: 'social_media') and two new
public delegates, setPagesRollout/getPagesRollout (moduleKey: 'pages'), added alongside them. The request DTO
was renamed accordingly (set-social-media-rollout.dto.ts → set-module-rollout.dto.ts,
SetSocialMediaRolloutDto → SetModuleRolloutDto) since its shape was already module-agnostic. Behavior is
byte-identical to Social Media’s, just keyed on pages instead — a plan gaining pages in its features (the
“everyone” case) or a tenant gaining moduleOverrides.pages = true (the “specific churches” case) both flow
through the exact code path already covered above.
Two bugs found and fixed while wiring up Pages Rollout, both around cache invalidation from platform-admin requests. Reported symptom: a platform admin enabled Pages for a tenant, but that tenant’s discuva-admin kept showing the plan-upgrade-required gate.
-
Missing invalidation entirely, in
setSocialMediaRollout’s “disabled” and “enabled, empty tenantIds” branches.PlanFeatureResolverService.resolve(tenantId)caches its result underplan-features:${tenantId}for 300s. Those two branches only calledcacheService.del()for tenants whose override changed in the loop that follows them — a tenant gaining or losing access purely because the plan’sfeaturesarray changed (the common case for both branches, and for any plainPATCH /platform/plans/:idedit to a plan’sfeaturesvia the Plans admin page) had no cache invalidated at all. Fixed two places:setModuleRollout()now calls a newinvalidateAllTenantCaches()(a full tenant scan + one cache invalidation per tenant — no reverse index exists from a plan to its subscribers, and tenant counts at this platform’s scale make a full scan cheap enough not to warrant one) at the end of all three branches, superseding the old per-affected-tenant-only invalidation;PlatformPlanService.updatePlan()(previously had noCacheServicedependency at all) now invalidates every tenant subscribed to the edited plan wheneverfeaturesorfeatureLimitschanges. -
The deeper bug: every invalidation in this file — including the pre-existing ones in
changeTenantPlanandsetModuleOverridethat predate this Pages work entirely — was computing the wrong Redis key.CacheService.del()/.get()/.set()all route through a privatescopedKey()that prefixes the key withtenant:${cls.get('tenantId') ?? 'global'}:. Platform-admin routes are deliberately excluded fromTenantMiddleware(seePlanFeatureResolverService’s own comment), so every request intoPlatformTenantService/PlatformPlanServicehas no tenant id in CLS at all — a barecacheService.del(\plan-features:${tenant.id}`)call from here always resolved totenant:global:plan-features:${tenant.id}, while the entry actually written byPlanFeatureResolverService.resolve()(called from inside an actual tenant-scoped request, where CLS genuinely holds that tenant's id) lives attenant:${tenant.id}:plan-features:${tenant.id}— a different key entirely, so the "invalidation" was always a silent no-op. This had gone unnoticed for as long as caching has existed here (tenant-brandingcache invalidation inupdateTenanthas the identical bug) because the TTL is only 300s — most manual testing outlasted the staleness window without anyone noticing the del() call never actually did anything. It surfaced clearly this time because the Pages tenant under test had a warm cache from moments earlier. **Fix:** a newPlatformTenantService.delTenantCache(tenantId, key)re-enters that tenant's CLS context viacls.runWith({tenantId}, () => cacheService.del(key))before deleting — every cache invalidation in the file (branding, plan-features, the rollout's blanket invalidation) now goes through it instead of callingcacheService.del()bare;PlatformPlanService.updatePlan()does the equivalent inline with its own newly-injectedClsService. Regression-tested by assertingClsService.runWithis actually called with the correct{tenantId}before the cache key is touched — a test that would have passed under the old bare-del()code by simply never noticing the key was wrong, if it only assertedcacheService.del` was called with the right raw key.
| Method | Route | Auth | Notes |
|---|---|---|---|
| GET | /platform/pages/rollout |
PlatformAdminGuard (PAGES_READ) | {enabled: boolean, tenantIds: string[]}, same derivation as Social Media’s but keyed on pages |
| PUT | /platform/pages/rollout |
PlatformAdminGuard (PAGES_WRITE) | {enabled: boolean, tenantIds: string[]} — full replace, not incremental |
Gated by its own dedicated PlatformAdminPermission.PAGES_READ/PAGES_WRITE, mirroring Social Media’s
SOCIAL_MEDIA_APPS_READ/WRITE exactly — originally shipped reusing TENANTS_READ/WRITE instead (reasoning
at the time: “Pages has no other platform-admin surface, so this is fundamentally the same tenant-access-
management action as the generic per-tenant module-override endpoint”), which quietly broke two things: the Admin
Roles screen had no distinct “Pages” checkbox group to grant/revoke (“not showing in the permission list” — a
real report, not a hypothetical), and Pages access could never be granted independently of full Tenants access.
Split via SplitPagesRolloutPermission migration (public schema, platform_admin_roles), which backfills
pages:read/pages:write onto every existing role that already had tenants:read/tenants:write respectively —
not scoped to a specific role name (role names change; see RenamePlatformSuperAdminRole) — so this is a pure
permission-model fix with no access change for any role that already reached Pages through Tenants.
Frontend: a standalone “Pages” page in discuva-platform’s sidebar (/pages, gated by pages:read in both the
sidebar nav item and the page’s own withAuth — previously wired to tenants:read, the frontend half of the same
bug above), containing nothing but the same RolloutPanel pattern Social Media uses (on/off switch + searchable
multi-select of churches). Distinct from the tenant-schema AdminPermission.PAGES_READ/PAGES_WRITE documented
in the Pages module section above, which gates the church-side admin builder instead — same permission names,
two entirely disjoint enums/guards (PlatformAdminPermission/PlatformAdminGuard vs AdminPermission/
AdminGuard), per PlatformAdminPermission’s own file comment. No apps/credentials section, since Pages has no
third-party OAuth concept to register.
departments was Pro-only by accident, corrected via AddDepartmentsToFreePlan. Unlike tithe, this had no
migration or comment ever recording it as a deliberate gate — and it directly contradicted KNOWN_MODULES’s own
required: true flag on departments (src/church-settings/constants/known-modules.constant.ts), which marks it
as a module a church can never disable, i.e. a foundational primitive, not an optional upsell. It’s also the FK
backbone for a wide swath of the app regardless of plan — attendance, worker profiles, finance requests, assets,
games, volunteer opportunities, announcements, and event reminders all reference department_id. A Free-tier
tenant was getting 403 PLAN_UPGRADE_REQUIRED on anything gated by @RequiresModule('departments') as a result.
Fixed by adding departments to free.features (idempotent, WHERE NOT ('departments' = ANY(features))) — pro
and its currency/interval variants (pro-usd, pro-annual, pro-usd-annual) already had it, inherited via
cloning when those variant rows were created.
Internal comps/discounts (Subscription.discountType/discountValue/discountReason/discountExpiresAt): a
platform-admin-only manual comp, set via PATCH /platform/tenants/:id/discount and cleared via
DELETE /platform/tenants/:id/discount (both TENANTS_WRITE, both require an existing Subscription row — apply
PATCH /platform/tenants/:id/plan first if the tenant has none yet). discountType is percentage (1–100,
validated server-side) or fixed_amount (cents); discountExpiresAt is optional — null means permanent until
explicitly removed. Deliberately never touches checkout or a payment provider — same spirit as
sponsoredByTenantId above, and for the same structural reason: Paystack’s recurring charges are driven
by a provider-side Plan object keyed on Plan.billingProviderPriceId, not a per-transaction amount override, so a
discount here can’t change what Paystack actually auto-renews at without creating a distinct provider Plan per
discount tier (out of scope for an internal comp). Its effect is bookkeeping only: PlatformAnalyticsService.mrrByCurrency()
sums each active, non-sponsored subscription’s effective (discounted) price via the shared
computeEffectivePriceCents() helper (src/billing/util/discount.util.ts) rather than the raw Plan.priceCents, so
reported MRR reflects comps; GET /platform/tenants also returns the four discount fields per tenant for display.
MRR is grouped by Plan.currency ([{ currency, mrrCents }]), never blended into one figure — same reasoning as
giving totals below: an NGN-priced and a USD-priced subscription summed together would be meaningless.
Branch Hierarchy (src/branch/)
Lets a parent church invite another church to join as a branch, and see a rollup of its branches’ member
count/attendance/giving — docs/MULTI_TENANT_MIGRATION.md §11’s “local compute, pushed rollups” design, now built.
A branch is a tenant like any other (own schema, own admin, own everything) — the only difference is
tenants.parent_tenant_id is set.
Onboarding: parent admin POST /branch/invites (email) generates a 64-char opaque token (not a JWT — has to be
looked up by value later, not decoded), emails it, and records a pending row in tenant_branch_invites
(public schema — has to live there since the invited church has no tenant of its own yet, the very thing the
invite exists to create). POST /signup accepts an optional branchInviteToken; SignupController resolves and
validates it (pending, unexpired) before enqueueing provisioning, so a bad/expired code fails fast without
creating a tenant row. TenantProvisioningProcessor only marks the invite accepted after provisioning actually
succeeds (see “Async Tenant Provisioning + Onboarding State Machine” above) — a subdomain collision or any other
provisioning failure leaves the invite still usable for a retry rather than burning it.
Plan sponsorship (sponsorPlan, optional on POST /branch/invites): the parent’s stated intent, carried on
the invite row and resolved at signup time — BranchInviteService.resolveInvite() returns a sponsoredPlanId when
sponsorPlan was true and the parent currently has a paid (non-free) subscription; a parent on Free has nothing
to sponsor onto, so this silently falls back to the normal independent Free-tier signup rather than erroring. When
set, TenantProvisioningService.provision() creates the branch’s Subscription directly on that plan with
sponsoredByTenantId pointing at the parent — no checkout, no independent payment. See Billing & Checkout above for
how sponsorship affects cancellation (blocked — it isn’t the branch’s plan to cancel) and MRR (excluded).
Rollup computation (BranchRollupScheduler, daily 03:00 church time, distributed-lock guarded): iterates every
active tenant — not just ones with a parent, since a branch invited later still needs history once linked — and for
each one manually enters that tenant’s CLS/transaction context (BranchRollupService.computeAndUpsertOne, same
mechanism as YoutubeLiveDetectionService/PlatformTenantService.impersonateTenant) to compute:
| Field | Definition |
|---|---|
memberCount |
Count of members with status = ACTIVE |
attendanceRate |
Same PRESENT/LATE/ATTENDED_ONLINE-over-a-window definition AttendanceService.getMyAttendanceSummary already uses per-member (ON_LEAVE excluded from both numerator and denominator), aggregated church-wide over the last 30 days instead. null when there are zero attendance rows in the window, not 0 |
totalGiving |
Sum of tithe_records.amount |
The public tenant_rollups row is upserted outside the tenant-context block (same convention as
YoutubeLiveDetectionService’s own public-row update) using the plain, non-tenant-scoped repository.
Parent-side overview (GET /branch/overview): reads only tenants WHERE parent_tenant_id = :self joined
against tenant_rollups — never reaches into a branch’s schema or shard directly, no cross-tenant read path exists
in this class at all to get wrong (§11.2).
Sharing consent (tenants.share_data_with_parent, tenants.share_giving_with_parent): without this, a
branch’s rollup became visible to its parent the instant an invite was accepted, with no notice or opt-out.
Self-service, settable only by the branch’s own admin via GET/PATCH /branch/sharing-consent — never by the
parent. Gates visibility at getOverview(), not computation — computeAndUpsertOne still computes and stores
every branch’s real numbers regardless (a branch’s own tenant_rollups row is its own data, useful for its own
future views too); consent only controls what’s exposed to a specific parent. shareDataWithParent defaults true
(being a branch structurally implies a reporting relationship — that’s the point of the feature, and the admin who
accepted the invite already knows a parent relationship is being formed) and gates every stat when false (all
fields null, sharingEnabled: false in the response — the parent still sees the branch exists, since they
invited it, just not its numbers). shareGivingWithParent defaults false regardless of the main flag — giving
specifically stays opt-in even when general sharing is on, matching how sensitive individual church finances are
treated everywhere else in this codebase (dedicated TITHE_READ permission, PII-scrubbing conventions).
Un-linking (either side can end the relationship): DELETE /branch/:branchTenantId (parent-initiated — detach
one of this tenant’s own branches, 404 if not actually linked to them) and POST /branch/leave
(branch-initiated — the current tenant leaves its own parent, 400 if it has none). Neither side is permanently
locked into a relationship it no longer wants. Both revoke a sponsored plan on the way out
(revokeSponsorshipIfSponsoredBy) if the departing tenant’s Subscription.sponsoredByTenantId matches the
relationship being severed — continuing free access sponsored by a parent it’s no longer affiliated with wouldn’t
make sense. A subscription sponsored by some other tenant (not the one being unlinked from) is left untouched.
Linking an already-onboarded tenant as a branch (TenantBranchLinkRequest, src/branch/service/branch-link-request.service.ts):
the invite flow above only covers a church that doesn’t have a tenant yet — it can’t be reused for two churches that
are already separate, fully-onboarded tenants, since there’s no signup step left to attach a token to. This is a
two-sided negotiation between two existing tenants instead: a would-be parent’s admin sends a request naming the
target’s subdomain (POST /branch/link-requests), and nothing about either tenant changes until the target’s own
admin, from inside their own tenant context, explicitly accepts or declines it (POST /branch/link-requests/:id/accept//decline) — the parent cannot accept on the target’s behalf, mirroring the same
“write intent first, mutate state only on confirmed action” discipline used everywhere else BYOK/checkout-shaped
flows appear in this codebase. TenantBranchLinkRequest (public schema, tenant_branch_link_requests) is the
sibling control-plane table to tenant_branch_invites, keyed on targetTenantId instead of an email+token pair
since the target already exists to be looked up directly.
Validation at creation time: target subdomain must resolve to a real tenant, a tenant can’t link itself
(target.id === parentTenantId), the target can’t already have a parent, and only one pending request between a
given parent/target pair may exist at a time. On accept, target.parentTenantId is set directly (no provisioning
involved — the target tenant already exists) and, if sponsorPlan was requested and the parent currently has a paid
plan, the target’s existing Subscription row is switched onto the parent’s plan
(planId/status: ACTIVE/cancelAtPeriodEnd: false/sponsoredByTenantId) — same fallback as
BranchInviteService.resolveInvite if the parent is on Free (silently skipped, not an error, since sponsorPlan was
only ever a request). Both sides get a best-effort email notification (target on request creation, parent on
accept/decline) via the same manually-entered tenant-context pattern SubscriptionLapseScheduler.findAdminEmail
already uses (looks up the tenant’s oldest active Admin, ordered by createdAt) — reused here through the shared
runInTenantContext helper rather than duplicated inline, since the recipient’s admin row lives in a schema this
service has no ambient CLS context for.
A parent can have any number of branches — getOverview()/listOutgoing() are both plain unbounded
WHERE parentTenantId = :self queries, and nothing in this feature restricts branch count.
GET /branch/link-requests/outgoing//incoming return BranchLinkRequestView, not the raw entity — the entity
only stores parentTenantId/targetTenantId UUIDs, so both list reads batch-fetch the referenced Tenant rows
(one IN query per list call) and enrich each row with parentTenantName/targetTenantName/targetTenantSubdomain
before returning, the same way BranchInviteService’s invites are already human-readable via the invite’s own
email column.
Un-linking (either side can end the relationship): DELETE /branch/:branchTenantId (parent-initiated — detach
one of this tenant’s own branches, 404 if not actually linked to them) and POST /branch/leave
(branch-initiated — the current tenant leaves its own parent, 400 if it has none). Neither side is permanently
locked into a relationship it no longer wants. Both revoke a sponsored plan on the way out
(revokeSponsorshipIfSponsoredBy) if the departing tenant’s Subscription.sponsoredByTenantId matches the
relationship being severed — continuing free access sponsored by a parent it’s no longer affiliated with wouldn’t
make sense. A subscription sponsored by some other tenant (not the one being unlinked from) is left untouched. This
applies identically regardless of whether the branch was linked via invite or via a link request — unlinkBranch/
leaveParent only look at Tenant.parentTenantId/Subscription.sponsoredByTenantId, not how the link was formed.
Routes (AdminGuard, tenant-scoped):
| Method | Path | Permission | Description |
|---|---|---|---|
| POST | /branch/invites |
BRANCH_WRITE | Body { email, sponsorPlan? } — creates a pending invite, emails the invite code. sponsorPlan: true means the branch is provisioned onto the parent’s current plan at signup, at no independent cost, if the parent is on a paid plan |
| GET | /branch/invites |
BRANCH_READ | This tenant’s own sent invites |
| DELETE | /branch/invites/:id |
BRANCH_WRITE | Revokes a pending invite — 400 if it’s already accepted/revoked |
| GET | /branch/overview |
BRANCH_READ | This tenant’s branches, each joined with its latest rollup, filtered by each branch’s own sharing consent |
| GET | /branch/sharing-consent |
BRANCH_READ | This tenant’s own { shareDataWithParent, shareGivingWithParent, parentTenantId, parentTenantName } — the latter two are null when this tenant isn’t a branch of anything, and are the only way for the frontend to know whether “Leave Parent” is relevant to show at all |
| PATCH | /branch/sharing-consent |
BRANCH_WRITE | Body is a partial of { shareDataWithParent, shareGivingWithParent } — only provided fields are applied; parentTenantId/parentTenantName are read-only and always echoed back |
| DELETE | /branch/:branchTenantId |
BRANCH_WRITE | Parent detaches one of its own branches — 404 if not linked to this tenant |
| POST | /branch/leave |
BRANCH_WRITE | This tenant leaves its own parent — 400 if it has none |
| POST | /branch/link-requests |
BRANCH_WRITE | Body { targetSubdomain, sponsorPlan? } — sends a link request to an already-onboarded tenant. 404 if the subdomain doesn’t resolve, 400 if self-link/target already has a parent/a pending request already exists |
| GET | /branch/link-requests/outgoing |
BRANCH_READ | Link requests this tenant has sent, as a would-be parent |
| DELETE | /branch/link-requests/:id |
BRANCH_WRITE | Parent revokes its own pending request — 400 if no longer pending |
| GET | /branch/link-requests/incoming |
BRANCH_READ | Link requests sent TO this tenant by a would-be parent |
| POST | /branch/link-requests/:id/accept |
BRANCH_WRITE | Target accepts — sets parentTenantId, applies sponsorship if requested and the parent is on a paid plan. 400 if not pending or this tenant already has a parent |
| POST | /branch/link-requests/:id/decline |
BRANCH_WRITE | Target declines — 400 if no longer pending |
Branch hierarchy UI is built in discuva-admin (/branch-hierarchy) — invite-sending, branches overview with
unlink, link-request sending/incoming-review, and a “this church” section showing sharing consent toggles +
leave-parent, only shown when parentTenantId is actually set (see GET /branch/sharing-consent above).
Multi-level hierarchy (branch-of-a-branch) is representable with zero further schema change (parent_tenant_id is
self-referencing) but only flat parent → branch is exercised by anything built so far.
Forms (src/forms/)
Admin-built dynamic forms — not tied to any one use case (events, surveys, sign-ups, and admin-recorded pastoral
records all reuse the same builder). A form has a visibility of MEMBERS, PUBLIC, or ADMIN_ONLY, an optional
link to an Event, and an ordered list of fields (TEXT, NUMBER, EMAIL, PHONE, TEXTAREA, DATE,
DROPDOWN, CHECKBOX — the latter two carry an options array). A PUBLIC form is fillable with no login at all
— the whole point of making it public — MEMBERS forms require an authenticated member/worker token, and
ADMIN_ONLY forms have no member-facing or public-facing fill surface at all — the only way a submission is
ever created against one is POST /forms/:id/submissions (admin-only; see below). ADMIN_ONLY exists for
record-keeping forms an admin fills in on someone else’s behalf rather than the subject self-submitting — e.g.
pastoral records (child naming, dedication, marriage, baptism), where the subject often has no reason or ability
to self-submit (a newborn being named has no account). This is a general-purpose escape hatch, not a fixed set of
pastoral-record types the way a hardcoded “Notes” module would be — an admin defines whatever fields a given
record type needs, and gets the same generic per-field analytics (see below) any other form gets, with zero new
backend code per record type.
Field order is always explicit, never left to Postgres’s default row order. Form.fields is an eager
@OneToMany relation with no orderBy of its own, so every formRepo.find/findOne call across
FormService/FormSubmissionService passes order: { fields: { order: 'ASC' } } explicitly — an unordered join
doesn’t reliably return rows in FormField.order order (or even the same order twice in a row), which surfaced as
the admin builder’s field list visibly reshuffling itself on every page load/refresh, with no reordering ever
actually requested. getById’s ordering alone covers most call sites (update, cloneForm, getAnalytics,
getSubmissionsCsv, the field-mapping in create/update all route through it); FormSubmissionService has its
own separate formRepo.findOne calls (getForMember, getForPublic, submitAsMember/submitAsPublic/
submitAsAdmin, updateSubmission, getMySubmission) and needed the same fix independently, since none of them
share getById.
Field description: each FormField has its own optional description (text, nullable) — helper text shown
under that field’s label while filling out the form (e.g. “Enter your legal name as it appears on your ID”),
distinct from Form.description, which introduces the form as a whole. Returned on every field DTO (create/update
request, and the public/member-facing PublicFormFieldDto) with no visibility restriction — unlike optionMetadata,
there’s nothing to hide here before submission.
Auto-fill: a field can carry an autoFillKey (FIRST_NAME/LAST_NAME/EMAIL/PHONE_NUMBER) — the
member-facing “get form for filling” endpoint resolves these against the logged-in member’s own profile and
returns them as suggestedValues alongside the field definitions, so the frontend can pre-fill without needing its
own copy of the mapping logic. Never applies to public/anonymous fills — there’s no member to infer from.
Online first-timer intake (Form.createsFirstTimers): a PUBLIC form can be flagged so that every submission
to it also creates a FirstTimer record (FollowUpService.createFirstTimerFromPublicForm) — the online-intake
counterpart to a walk-in visitor being registered by a Follow-Up worker. The same autoFillKey mechanism doubles
as the field-mapping: FormSubmissionService reads the submitted answers back out via whichever fields carry
FIRST_NAME/LAST_NAME/PHONE_NUMBER/EMAIL. Enforced at create/update time (FormService.assertValidFirstTimerConfig):
the form must be PUBLIC, and each of FIRST_NAME/LAST_NAME/PHONE_NUMBER must be mapped to a field that is
itself required: true — not just present — since CreateFirstTimerDto’s own class-validator decorators never
run against a service-constructed object, so this is the only real guard against an empty name/phone reaching
FirstTimer. The resulting FirstTimer.source is always forced to ONLINE regardless of whatever the form’s own
fields submit, and it’s created with no actor (memberCreatorId/adminCreatorId both unset) — same round-robin
Follow-Up-worker assignment and task-creation path as every other first-timer registration route. This side effect
is fault-tolerant: a failure (e.g. no active Follow-Up worker configured yet) is logged but never blocks the
submission itself from saving — an anonymous visitor filling this in from a QR code never sees an error. The
intended distribution path is a QR code linking to the form’s public URL, printed/displayed for walk-up scanning
or shown during a livestream (generated client-side in discuva-admin — no backend endpoint involved).
Submissions are keyed by field id inside a jsonb blob (FormSubmission.answers: Record<fieldId, value>),
not a normalized per-answer table — editing or removing a field later never requires migrating past submissions; a
removed field just leaves a harmless orphaned key behind in old submissions’ JSON, still readable/exportable.
Admin notification on submission (Form.notifyOnSubmission, default false): a per-form opt-in — when on,
FormSubmissionService.notifyAdmins emails every active Admin whose adminRole.permissions includes
FORMS_WRITE (form-submission-new template) after a successful save, fire-and-forget (a failure here is logged,
never surfaces to the submitter). Gated by both this per-form flag and the tenant-wide
EmailCategory.FORM_SUBMISSION toggle (§10’s EMAIL_FORM_SUBMISSION_ENABLED platform kill switch, and the
per-church override under Notification Settings → Automated Emails) — the same defense-in-depth every other
EmailCategory already has. Only fires from submitAsMember/submitAsPublic; submitAsAdmin never notifies,
since an admin recording something on someone’s behalf doesn’t need to be told about it.
Per-option link + description, an always-shown general action, and dynamic post-submission “next steps”: a
DROPDOWN/CHECKBOX field’s options can each carry optionMetadata: Record<option, { url?, description? }> —
e.g. a “Department” dropdown where each department option has its own WhatsApp group link and one-line
description. A form separately designates one DROPDOWN field (Form.nextStepsField, nextStepsFieldId on
create/update — CHECKBOX is rejected here, since its multi-select answers don’t map to “one selected option’s
metadata”) and every submit endpoint now returns { submissionId, nextSteps: { message, generalAction, selectedOption } } instead of the raw FormSubmission row: message is the form’s own postSubmitMessage (or
null); generalAction — { label, url } | null — is the form’s own generalActionUrl/generalActionLabel
(FormService.assertValidGeneralAction: both must be set together, or both left empty, and the URL must parse),
an always-shown second call-to-action independent of what was answered (e.g. “Join the Main Volunteer Group”,
same for every submitter); selectedOption — { value, url, description } — is resolved from whichever option
the visitor actually picked, or null if no nextStepsField is configured. These two links are independent and
both optional — a form can have either, both, or neither. Only the chosen option’s metadata is ever returned
— the public GET /forms/public/:id (and the member-facing equivalents) strip optionMetadata from every field
entirely, so no other department’s link is ever visible before that option is submitted, and generalActionUrl/
generalActionLabel are likewise omitted from that response — both call-to-actions only ever appear on the
post-submission response, never before. Validated at create/update (FormService.assertValidOptionMetadata):
every optionMetadata key must be one of that field’s own options, and any url must parse as a real URL.
Ranked conditional overrides for the post-submission message/action (Form.postSubmitOutcomes, nullable jsonb
array): each entry is { conditions: [{ fieldId, operator, value }, ...], message, hideMessage, actionUrl, actionLabel, hideAction } — conditions reuses FormField.visibilityRule’s exact same shape/operator set
(equals/notEquals/includes), just as an array instead of a single condition, ALL of which must match (AND)
for that outcome to apply. FormSubmissionService.resolvePostSubmitContent evaluates postSubmitOutcomes in
array order at submit time against the just-submitted answers — the first outcome whose conditions all match
wins. Resolution is per-field, not a paired all-or-nothing swap, and the message and the actionUrl/
actionLabel pair are each an independent three-way choice on the winning outcome:
hideMessage/hideActiontrue→ show none of that piece for this response, even though the form has a staticpostSubmitMessage/generalActionUrl+generalActionLabeldefault.normalizePostSubmitOutcomesforcesmessage/actionUrl/actionLabeltonullwhenever their own hide flag istrue, so there’s never an ambiguous “hidden but a value is also set” state in storage —resolvePostSubmitContentnever even reads those fields when the hide flag is set.- hide flag
falseand the value isnull→ inherit the form’s own static default, independently per piece — so one rule can override just the button while leaving the default message alone, or vice versa. - hide flag
falseand the value is set → use that custom value.
No match (or no outcomes configured) falls back to the static fields unchanged, so an older form with none
behaves exactly as before, and a form created before this three-way choice existed (both hide flags absent) reads
as false for both — identical to its old inherit-on-null-only behaviour. Condition evaluation is shared with
isFieldVisible via a new evaluateCondition helper rather than duplicated. Deliberately independent of
nextStepsField/optionMetadata’s per-selected-option selectedOption link (see above), which still resolves
and is returned alongside whatever postSubmitOutcomes produces — the two mechanisms answer different questions
(“what does the option they picked lead to” vs. “does this whole submission, considered together, warrant a
different message/action”) and compose rather than conflict.
Validated at create/update (FormService.assertValidPostSubmitOutcomes): every condition’s fieldId must reference
a field already present among the incoming fields — the same real-world constraint visibilityRule has, since no
field has an id yet on create(), outcomes are edit-only in practice — and each outcome’s own actionUrl/
actionLabel reuse assertValidGeneralAction’s pairing check (both set or both empty), skipped entirely when
hideAction is true (the pair is forced to null regardless, so there’s nothing meaningful to pair-check, and
a stray value the client didn’t clear shouldn’t block the save). On update, validated against dto.fields when
the request touches fields (so a field this same request is about to delete can’t be referenced) or the form’s
current fields otherwise. CloneFormDto has no override for postSubmitOutcomes — like visibilityRule, every
condition’s fieldId points at a source field id that won’t exist post-clone, so there’s no sensible value an admin
could supply before the clone’s own fields exist. Instead cloneForm always inherits and re-matches it from the
source by label (FormService.remapClonedPostSubmitOutcomes, the same by-label technique remapClonedVisibilityRules
uses, carrying hideMessage/hideAction through unchanged via its object spread) — if even one condition inside an
outcome can’t be re-matched, the whole outcome is dropped rather than left partially broken (an outcome needs
every one of its conditions to mean anything), mirroring remapClonedVisibilityRules’s own “drop rather than leave
dangling” stance.
Duplicate-submission prevention: a form can designate one field (Form.dedupField, dedupFieldId on
create/update — DROPDOWN/CHECKBOX excluded as poor dedup keys) whose submitted value must be unique per form. On
submit, that field’s value is normalized (phone-normalized if it’s a PHONE field, else trimmed+lowercased) into
FormSubmission.dedupValueNormalized, enforced by a DB-level partial unique index
((form_id, dedup_value_normalized) WHERE dedup_value_normalized IS NOT NULL) — not just an application check, so
two near-simultaneous duplicate submissions can’t both slip through a race. A unique-constraint violation
(err.code === '23505', same pattern as SmallGroupService) is translated into a friendly BadRequestException
carrying a structured code: 'DUPLICATE_SUBMISSION' alongside its message (same “extra keys spread into the
response body” convention as PlanGuard’s PLAN_UPGRADE_REQUIRED — see http-exception.filter.ts), so the fill
page can render a distinct “you’re already registered” screen instead of routing it through a generic error
banner, rather than just matching a display string.
Phone normalization (src/utility/decorators/normalize-phone.decorator.ts, normalizePhoneNumber, backed by
libphonenumber-js — this platform is multi-tenant/multi-country, not Nigeria-only, so hand-rolled Nigeria-shaped
regex would incorrectly reject or mangle any other country’s number): every PHONE-type field’s submitted value is
parsed and normalized to E.164 before it’s persisted. A number already carrying its own country code (a leading
+, or a bare international dialing code) parses correctly for any country regardless of the tenant’s own
default — e.g. a Nigerian church’s diaspora member submitting a UK number still normalizes correctly. A
LOCAL-format number with no country code (e.g. a bare 0801234567) is interpreted against defaultPhoneRegion,
derived once per FormSubmissionService instance from the CURRENCY_LOCALE env var’s region subtag (en-NG →
NG) — the same per-deployment default already used for currency/date formatting elsewhere (TitheService,
PdfService, EventReminderService), not a Nigeria-specific hardcode. Anything that doesn’t parse as valid for
its (explicit or assumed) country returns null (a required PHONE field that fails to normalize is rejected
with a 400 — never silently mangled or dropped). The normalized value is what’s actually stored in answers, so
exports, analytics, and dedup all ever see one canonical shape for the same real number. The same rule applies to
every phone field on the API — see Phone Number Storage (E.164) below.
Phone Number Storage (E.164): every phone number written through the API is stored in E.164 (+ + country code
- national number, e.g.
+2348012345678). Clients may send local format (08012345678), a bare country code (2348012345678) or spaced/dashed input — the DTO decorators@NormalizePhone() @IsNormalizedPhone()convert it, using theCURRENCY_LOCALEregion (defaultNG) for numbers without a country code. Anything that doesn’t parse as a valid number for its country is rejected with400and a region-aware message built fromCURRENCY_LOCALE, e.g.Please enter a valid phone number (e.g. 0802 123 4567), or include the country code (e.g. +44…) for numbers outside Nigeria.(invalidPhoneMessage(); clients should show it as-is). A blank or whitespace-only phone is treated as not provided — accepted on optional fields, rejected with the same message on required ones (first-timer phone). Exception: onPATCH /members/meandPATCH /members/:id,phoneNumber: ""ornullclears the stored number (@NormalizePhone({ clearable: true })); omitting the field leaves it unchanged. Length/prefix rules come fromlibphonenumber-jsper country — never hardcode digit counts.
| DTO | Field | Endpoint(s) |
|---|---|---|
SignupDto |
phoneNumber |
POST /auth/signup, POST /members (admin create) |
UpdateMemberDto |
phoneNumber |
PATCH /members/:id |
UpdateMyProfileDto |
phoneNumber |
PATCH /members/me |
CreateGuardianDto |
phoneNumber |
POST /children-church/children/:id/guardians |
EnrollGuestDto / BulkGuestEntryDto |
phone |
POST /classes/enroll/guest, POST /classes/enroll/guests/bulk |
CreateFirstTimerDto / UpdateFirstTimerDto |
phone |
POST /follow-up/public/first-timer, POST/PATCH /follow-up/first-timers[/:id], POST/PATCH /admin/follow-up/first-timers[/:id] |
CheckInFirstTimerDto |
phone |
POST /sunday-school/sessions/:id/checkin-first-timer |
CreateConvertDto |
phone |
POST /evangelism/converts |
Also normalized in-service: member bulk import, group phone-only entries, PHONE form fields, and SMS recipients at
send time (recipients are deduped after normalization, so 0801… and +234801… send once). Not normalized: ExternalPayee.contactPhone (finance contact, never messaged).
Backfilling existing data: npm run phones:normalize:all-tenants (prod: phones:normalize:all-tenants:prod) walks
every active tenant and rewrites members.phone_number, group_members.phone_number, child_guardians.phone_number,
first_timers.phone, converts.phone and guests.phone to E.164. Dry run by default — prints per-tenant counts and
every row needing manual review; pass -- --apply to write. Unparseable numbers are left untouched and reported;
a group_members row whose normalized number already exists in the same group is skipped and reported (never merged
or deleted). Idempotent — safe to re-run.
Form branding — cover image and logo: Form.coverImageUrl/coverImagePublicId and Form.logoUrl/
logoPublicId (mirrors Tenant.logoUrl/logoPublicId’s shape) are set via dedicated upload endpoints (see table
below), Cloudinary-backed (CloudinaryService.uploadBuffer, folders form-covers/form-logos) with the same
“delete the previous asset only after the new one is safely saved” ordering used by TenantInfoController’s own
logo upload. Both are optional and independent of the tenant’s own logo — the public fill page renders a form’s
own logo in place of the generic tenant logo when set, and falls back to the tenant logo otherwise; a cover image
renders as a banner above the form title when set, with no fallback (most forms have none).
Audience restriction via Contact List: a MEMBERS-visibility form can be restricted to members of one Group
(“Contact List” in the admin UI) — Form.audienceGroup/audienceGroupId. audienceGroupId is rejected outright
on a PUBLIC/ADMIN_ONLY form (FormService.assertValidAudienceGroup) — there’s no member identity to check
against there. When set, GET /forms/member filters it out of the list for anyone not in that group (an EXISTS
subquery against group_members, mirroring AnnouncementService.getForMember’s own group-membership check), and
GET /forms/member/:id / POST /forms/member/:id/submit 404 for an outside member exactly as if the form didn’t
exist — no distinct “you’re not allowed” response that would leak the form’s existence. audienceGroupId: null
(explicit) clears the restriction; omitting it on a PATCH leaves the current value untouched. This deliberately
reuses the existing Group/Contact-List feature rather than introducing a parallel “specific list” concept — e.g. a
church restricting a form to department heads first builds a “HODs” Contact List, then points the form at it.
Field diff-sync on update: PATCH /forms/:id’s fields array is diffed against the form’s existing fields —
an incoming field with an id updates that row in place (keeping the id stable so existing submissions’ answer
keys stay meaningful), one without an id is a new field, and an existing row missing from the incoming array is
deleted. Omitting fields entirely from the PATCH body leaves them untouched.
Quiz + Voting (Form.purpose: STANDARD | QUIZ | VOTE, default STANDARD): two purpose-built configurations
of the same engine — every existing form is STANDARD and behaves byte-for-byte as before this existed.
VOTEmust beMEMBERSvisibility (FormService.assertValidPurposeConfigrejectsPUBLIC/ADMIN_ONLYoutright — there’s no secret-ballot/anonymity design here, a vote’s submissions carry the samememberidentity every otherMEMBERSsubmission does).Form.oneResponsePerMember(boolean) enforces identity-based one-submission — distinct from the pre-existingdedupFieldmechanism, which dedupes on a submitted value (e.g. a phone number), not on who submitted.submitAsMemberchecks this before saving and throws the sameDUPLICATE_SUBMISSIONshapededupField’s23505path already throws ("You've already voted."), so the frontend’s existing duplicate-submission handling needs no changes. AVOTEfield’soptionsmay not contain a case-insensitive-trimmed duplicate ("Vote choices must be unique — ... is listed twice.") — a data-quality guard so two candidates can’t accidentally collide. The tally itself needs no new code at all:GET /forms/:id/analytics’s existingchoices: [{option, count, percentage}]breakdown (anyDROPDOWN/CHECKBOXfield) already is the vote count.choicesis sortedcount DESC(stable — a tie keeps the options’ original declared order), so the leading choice is always index 0 rather than something an admin has to scan every percentage to find; discuva-admin’s Analytics panel renders a “Leading” badge on it forVOTEforms specifically (“Tied” instead, when the top two choices are exactly even).QUIZadds auto-grading.FormField.correctOptions(nullable text array,DROPDOWN/CHECKBOXonly, validated against that field’s ownoptions) marks which value(s) are correct;FormField.points(nullable smallint, meaningful only alongsidecorrectOptions;nullmeans1, the flat per-question value scoring always used before this column existed) is how many marks that question is worth.FormSubmissionService. scoreQuizSubmissioncompares each scorable field’s submitted answer at submit time and writesFormSubmission. score/maxScore(both nullable, bothnullfor non-QUIZsubmissions) —DROPDOWNis correct when the single submitted value is incorrectOptions,CHECKBOXonly when the submitted set exactly equalscorrectOptions(no partial credit); a correct answer earns that field’spoints(default1), andmaxScoreis the sum of every scorable field’spoints, not a flat count of scorable fields. A field with nocorrectOptionsset simply doesn’t count toward the total — aTEXT/TEXTAREAshort-answer/essay question (allowed on aQUIZ— see below — but never auto-gradable) is never scored, reviewed manually by a teacher/ admin instead.Form.revealScoreImmediately(defaulttrue) controls whether the score comes back on the submit response right away, or is withheld ({scorePendingUntil: closesAt}instead of{score, maxScore}) until the window closes — guards against an early finisher’s result (and by extension which questions they got right) leaking to classmates sitting the same quiz during a shared open window. A withheld score is still computed and stored at submit time;GET /forms/member/:id/submissionreveals it oncenow > closesAt. No per-person score list on the member side — a submitter only ever sees their own via that route orGET /forms/member/history(below) — but the admin does get one:GET /forms/:id/submissions?sortBy=score(FormService.getSubmissions) sortsNULLS LASTbyscore DESC(a query-builderorderBy, not TypeORM’s plainorderoption, which defaultsDESCtoNULLS FIRSTin Postgres — that would rank an unscored submission, e.g. one from before an admin addedcorrectOptionsto a question, above every real score) withcreatedAt DESCas the tiebreak. discuva-admin’s Submissions panel surfaces this as a “Most Recent”/“Highest Score” toggle forQUIZforms, rendering a Rank/Participant/Score/Submitted-At table (the same compact-table treatmentVOTE’s Voter/Choice/Voted-At table already gets, instead of the generic multi-field card layout).- Member-facing gaps found after shipping (both fixed): (1) discuva-member’s
submit()/updateSubmission()(hooks/use-forms.ts) only ever extractedres.data.data.nextSteps, silently droppingscore/maxScore/scorePendingUntil— those three are top-level siblings ofnextStepsinFormSubmitResponseDto, not nested inside it, so aQUIZsubmitter never actually saw their score even withrevealScoreImmediately: trueand a correctly-computed score server-side. Fixed by aflattenSubmitResulthelper that merges the two shapes into the one flat object the fill page (app/forms/[id]/page.tsx) already readsresult.score/result.messageoff of interchangeably. (2)GET /forms/member/:id(getForMember) gainsattemptCount— how many times this member has already submitted this form (submissionRepo.count, scoped to(form, member)) — so the fill page can show “You’ve attempted this N times” alongsideForm.oneResponsePerMember(already present on the returnedformobject, just not previously surfaced in the member UI) instead of a member only discovering whether retakes are allowed by trying to submit again. GET /forms/member/history(FormSubmissionService.getMyHistory) — “see scores/votes for previous events.” EveryQUIZ/VOTEsubmission the calling member has ever made, most recent first, regardless of whether the form itself is still active (a member can still look back at last year’s election vote or an old sermon quiz score after the form is deactivated/archived). Optional?purpose=QUIZ|VOTEnarrows it to one. Paginated (PaginationResponseDto), each row{formId, formTitle, formPurpose, submittedAt, score, maxScore, choice}—score/maxScorepopulated only forQUIZrows,choice(the submitted answer, joined if an array) only forVOTErows, since aVOTEform is always a single choice question (assertValidPurposeConfigenforcesvisibility: MEMBERSand rejects anything butDROPDOWN/CHECKBOXfields forVOTE, so “the first answer” is unambiguous). Deliberately excludesSTANDARDsubmissions — an arbitrary multi-field form’s answers aren’t a score/choice worth surfacing in a history list the same way.- A
VOTE/QUIZfield’sfieldTypeis restricted, not the full 9-type pickerSTANDARDforms get (FormService.assertValidPurposeConfig, checked againstCHOICE_FIELD_TYPES/QUIZ_FIELD_TYPESat create/update — the exact allowlist discuva-admin’sfield-editor.tsxalso filters its type<select>to, so an admin never sees a choice the server would reject). AVOTEfield must beDROPDOWN/CHECKBOX— a voter’s identity is already the logged-in member, so there’s never a reason to collect a name/email/phone/etc. alongside a ballot. AQUIZfield may additionally beTEXT/TEXTAREA(for the manually-reviewed short-answer case above); every other type (NUMBER,EMAIL,PHONE,DATE,FILE) has no real place on either and is rejected outright —"<label>": a vote field must be Dropdown or Checkbox/"<label>": a quiz field must be Dropdown, Checkbox, Text, or Long Text. discuva-admin’s Purpose switcher coerces any existing field outside the new purpose’s allowlist toDROPDOWNthe moment an admin picksVOTE/QUIZ(coerceFieldsForPurpose), rather than leaving it to block Save with no explanation. - A
QUIZsubmission’sForm.editableAfterSubmitis always forced tofalse, regardless of what a create/ update/clone request sends — enforced at all three write paths (FormService.create/update/cloneForm) plus a defense-in-depth rejection directly inFormSubmissionService.updateSubmission("Quiz answers cannot be edited after submitting.") so the invariant holds even if it were ever set incorrectly upstream. Letting an answer be revised after the score was already computed — and very possibly shown, whenrevealScoreImmediatelyis on — would turn “test” into “look up the answer key and fix it.” The admin edit form reflects this: forpurpose === QUIZthe usual “Let members edit their response” checkbox is replaced with an explanatory locked notice, and an “Allow retakes” checkbox (wired toForm. oneResponsePerMember, inverted — checked means retakes allowed) is the correct way to let a member attempt aQUIZagain, via theFormAttempt/retake flow below, never a silent edit of the old submission.
Time-boxing (Form.opensAt/closesAt, both nullable timestamptz — an exact date-and-time instant, not
date-only like ChurchCalendar.startDate/endDate): available on any purpose but only surfaced in the admin
UI for QUIZ/VOTE. Both null (the default) means always open, unchanged for every existing form.
FormSubmissionService.assertWithinWindow gates submitAsMember/submitAsPublic/updateSubmission (editing an
already-cast vote after the window closes is blocked too, not just a fresh submission) — independent of the
pre-existing isActive flag; both gates must pass. Deliberately not applied to submitAsAdmin — an admin
backfilling/correcting a record is an intentional override, the same posture that method’s unrestricted-visibility
access and skipped stripHiddenAnswers pass already have. An admin-entered wall-clock value (e.g. “5:00 PM”) is
interpreted in the church’s own configured timezone via DateService.toChurchInstant (fromZonedTime against
the TIMEZONE env var, the same trick DateService.startOfDay/endOfDay already use), not the browsing admin’s
or the server’s — so “closes at 5 PM” means 5 PM church time regardless of who sets it or from where.
A QUIZ with Form.timeLimitMinutes set (smallint, nullable) additionally gets a per-attempt countdown —
scoped to MEMBERS visibility only (an anonymous PUBLIC submission has no identity for a personal clock to
track). New entity FormAttempt (form_attempts: form, member, startedAt, expiresAt, submission
nullable — set once consumed) has no hard unique constraint on (form, member); a member can accumulate more
than one row over time, and FormAttemptService decides what to do with them:
POST /forms/member/:id/start(startOrGetAttemptById→startOrGetAttempt) returns the existing attempt unchanged if one is already in progress (unconsumed, unexpired) — idempotent, so a page refresh mid-quiz doesn’t reset the clock.opensAt/closesAtgate starting a new attempt, not finishing one already running.- If the most recent attempt is already consumed or expired unconsumed, a fresh attempt is only allowed when
Form.oneResponsePerMemberisfalse(retakes allowed) — otherwise"You've already completed this quiz." expiresAt = min(startedAt + timeLimitMinutes, closesAt ?? Infinity), computed and stored once at start — a later admin edit totimeLimitMinutes/closesAtnever retroactively changes an attempt already in progress (changing the rules mid-exam for people already sitting it would be a bug, not a feature).submitAsMemberfor such a form callsFormAttemptService.assertValidForSubmitbefore saving — requires an unconsumed attempt within its ownexpiresAt, else"Start the quiz before submitting."/"Time's up — this attempt has expired."— and consumes it (consumeAttempt, setsattempt.submission) once the submission actually saves.GET /forms/member/:id’s response gainswindowState('OPEN' | 'NOT_OPEN_YET' | 'CLOSED', computed fromopensAt/closesAt) andattempt({startedAt, expiresAt} | null, the current in-progress attempt if any) — lets the fill page show the right state (not-yet-open, closed, or “resume your in-progress attempt”) instead of only surfacing a window/attempt error at submit time.
Cloning (POST /forms/:id/clone, FormService.cloneForm): modeled on PrayerConfigService.cloneProgram —
title is the only required field on CloneFormDto; every other scalar follows an “omitted = inherited from the
source, explicit null = cleared, value = override” convention (same as UpdateFormDto’s nullable fields). The
clone always starts isActive: false (an admin reviews it before it goes live) and with no cover/logo — the two
Forms would otherwise share a Cloudinary publicId, so removing the clone’s cover would delete the original’s.
fields themselves are not part of the DTO: they’re always deep-copied from the source verbatim, each getting
a fresh id — a clone’s fields are edited afterwards via the normal PATCH, not at clone time. dedupField/
nextStepsField are re-matched by label against the freshly-cloned fields (the only stable key once ids are
gone), the same .update()-not-.save() two-phase approach applyCrossFieldRefs already uses. FormSubmissions
are never cloned. purpose/oneResponsePerMember/timeLimitMinutes/revealScoreImmediately and each field’s own
correctOptions are all carried over verbatim (the same “worth preserving structural behaviour” reasoning as
fields itself), but opensAt/closesAt always reset to null — a specific schedule never makes sense to copy
onto a brand-new, unreviewed clone.
Answer validation happens server-side against the form’s actual field definitions, not via a fixed DTO shape
(SubmitFormDto.answers is just Record<string, unknown> — the schema is per-form, not knowable at compile time):
required fields must be present and non-empty, DROPDOWN/CHECKBOX values must be one of the field’s
configured options, and EMAIL/NUMBER/DATE fields are format-checked (isValidEmail/isValidNumber/
isValidDateString, src/utility/decorators/form-answer-validators.ts — thin wrappers around class-validator’s
isEmail/isNumberString/isDateString). Format checks are validate-only: unlike PHONE’s
normalizePhoneNumber, the stored answer is never rewritten — a NUMBER answer stays whatever numeric string was
submitted, so CSV export/analytics’ existing string-tolerant handling of answers is unaffected. An empty optional
field skips both the options and format checks, same as it always has.
Bound constraints (minValue/maxValue, minLength/maxLength, minSelections/maxSelections, all nullable
on FormField): each pair only applies to its matching fieldType — minValue/maxValue to NUMBER,
minLength/maxLength to TEXT/TEXTAREA, minSelections/maxSelections to CHECKBOX — enforced at
create/update time by FormService.assertValidFieldConstraints (rejects a bound set on the wrong fieldType
outright, and max < min when both are set on the same field) — this check treats an explicit null the same as
omitted (== null, not === undefined): the admin field editor sends an explicit null for every bound that
doesn’t apply to whatever fieldType a field is switched to (e.g. picking PHONE clears minValue/maxValue), and
since a form’s fields array is always saved as a full replace rather than a per-property patch, “omitted” and
“explicitly cleared” mean the same thing here — unlike the top-level Form scalars in update(), which do
distinguish the two. FormSubmissionService.validateAnswers re-checks
the bound at submit time (validateFieldBounds, after validateFieldFormat so a malformed NUMBER answer is
already rejected before its value bound is even checked) — a null bound means unbounded on that side, and an
empty optional field skips bound checks the same way it skips every other check. PublicFormFieldDto carries all
six through unchanged for the fill UI’s own native-input hinting (min/max/minLength/maxLength HTML
attributes; minSelections/maxSelections has no native HTML equivalent, shown as helper text instead) — that
client-side hinting is convenience only, not the real enforcement.
Custom pattern validation (FormField.validationRegex/validationMessage, both nullable strings): TEXT/
TEXTAREA only — a submitted answer must match new RegExp(validationRegex).test(value)
(FormSubmissionService.validateFieldPattern, run after validateFieldFormat/validateFieldBounds in
validateAnswers), rejected with validationMessage if set, else a generic "<label>" is not in the required format. FormService.assertValidFieldPattern (called from both create/update, alongside
assertValidFieldConstraints) rejects a pattern set on the wrong fieldType outright, and rejects a
syntactically invalid regex (new RegExp() throwing) as a 400 at save time rather than only surfacing the
first time someone submits against it. Both fields are capped at 200 characters at the DTO level
(@MaxLength(200)) — defense-in-depth against a pathological catastrophic-backtracking pattern; the value is
admin-authored (AdminGuard + FORMS_WRITE), not visitor input, but the cap costs nothing and narrows the blast
radius regardless. PublicFormFieldDto carries both through for the fill UI’s own hinting: the native HTML
pattern/title attributes only apply to <input type="text"> (not <textarea>, not type="email"/"tel"/
"number"/"date"), so TEXT gets the real browser-native attributes and TEXTAREA falls back to a plain
helper-text hint — both convenience only, not the real enforcement. The admin field builder shows a live
client-side regex-syntax check (red border + inline warning) purely as authoring feedback; the server re-checks
syntax independently at save time regardless.
Multi-page forms (FormField.pageIndex, smallint, default 0): a plain grouping key, not a first-class
FormPage entity — every field defaults to page 0, so an older form (or one that never opts into pagination)
renders and submits exactly as before. Grouping/rendering is entirely a member-frontend concern
(components/forms/paginated-form-fill-fields.tsx’s PaginatedFormFillFields, wrapping FormFillFields per
page rather than replacing it — used by both the member and public fill pages): page count is derived from
Math.max(...fields.map(f => f.pageIndex)), Back/Next navigate between pages, and a Next click does a
client-side required-field check on the current page only (mirrors validateAnswers’ own isEmpty check, but
is convenience-only — a page not yet visited is unmounted, so it never gets native HTML5 validation at all).
There is deliberately no backend pagination logic and no draft/partial-save: a submission is still one atomic
final POST of every page’s answers together, same as a single-page form always was — an abandoned mid-form
visitor simply never submits. PublicFormFieldDto carries pageIndex through unchanged for the fill UI’s own
grouping. The admin field builder (field-editor.tsx) gets a numeric “Page” input per field (1-based in the UI,
0-based in pageIndex) and visually groups the field list by page once a form actually uses more than one —
each page section is independently collapsible; a new field added via “Add Field” continues on whichever page
was last in use rather than always resetting to page 1.
Conditional/branching logic (FormField.visibilityRule, nullable jsonb — { fieldId, operator, value },
operator one of equals/notEquals/includes): one rule concept, lives on FormField only — there’s no
separate page-level rule; a page effectively disappears when every one of its fields is hidden by its own rule,
evaluated by the same function on both the admin builder and the fill renderer. Deliberately a plain jsonb
column, not a @ManyToOne relation — unlike dedupField/nextStepsField, this never enters TypeORM’s
topological sorter at all (a jsonb column carries no relation semantics for it to see), so it sidesteps the
Form.fields cyclic-dependency class of bug entirely rather than needing that pair’s .update()-not-.save()
workaround — set directly in the same fieldRepo.create()/fieldRepo.save() call as every other field property.
fieldId must reference another field on the same form that already has an id — the same constraint
dedupFieldId/nextStepsFieldId have in practice, since the admin picker only ever offers existing fields
(FormService.assertValidVisibilityRules, called from both create/update; rejects a self-reference too). A
direct consequence: a rule can never be set at create time (no field has an id yet at that point) — same
real-world constraint as dedup/next-steps, which are also edit-only in the admin UI. cloneForm re-matches each
rule’s fieldId to the freshly-cloned target by label (remapClonedVisibilityRules, the same by-label technique
applyCrossFieldRefs uses) — a rule whose target somehow didn’t survive the clone is silently dropped rather
than left dangling.
When the trigger field has a fixed option set (DROPDOWN/CHECKBOX), assertValidVisibilityRules also rejects a
value that isn’t one of that field’s own options — a value that can never match anything a visitor actually
submits would otherwise fail silently (the rule just never fires, with no error anywhere to explain why). The
admin field builder avoids this case by construction: once a trigger with options is selected, the condition’s
value input becomes a <select> of that field’s own options instead of free text, so a typo or case mismatch
can’t be typed in the first place — this validation is the server-side backstop for anyone calling the API
directly.
FormSubmissionService.isFieldVisible evaluates a rule against the submitted (or, client-side, in-progress)
answers: equals/notEquals compare a scalar answer as a string (an array/object answer — CHECKBOX/FILE — is
never treated as “equal” to a typed value, rather than falling back to a meaningless Object-stringified
comparison); includes array-contains for a CHECKBOX target or substring-matches for free text. validateAnswers
calls this before a field’s required check and skips every other check too when hidden — a
conditionally-hidden field never blocks submission and its leftover value (if any) is never validated, regardless
of what the client happened to render. No cycle detection anywhere: each field’s visibility is evaluated
independently against the answers, never against another field’s own computed visibility, so a rule chain (or an
accidental cycle) can’t recurse.
A hidden field’s leftover value is stripped before it’s ever persisted, not just exempted from validation
(FormSubmissionService.stripHiddenAnswers, run after validateAnswers succeeds so validation itself keeps seeing
every raw submitted value — only what actually gets saved is affected). The fill UIs only stop rendering a field
once a prior answer hides it; they never clear that field’s own local edit state, so a value typed before the
field went hidden is still present in the submit payload. Left in the saved record, that stale answer would
silently pollute CSV export and FormService.getAnalytics with a response the submitter never actually confirmed
seeing. Applied on submitAsMember/submitAsPublic/updateSubmission — deliberately not on submitAsAdmin,
since the admin’s own record-entry UI shows every field unconditionally regardless of visibilityRule, so any
answer reaching that path was something an admin actually saw and typed, never a stale leftover. Every field’s
hidden/visible determination is evaluated against the same fixed pre-strip snapshot regardless of which order
fields happen to be stripped in, so one field being hidden can never change another field’s own visibility result.
A FILE field’s now-unclaimed upload (hidden, so its {url, publicId} answer is stripped rather than saved) is
picked up by the normal 48h orphan sweep like any other abandoned upload, rather than being treated as claimed.
On the member/public fill side, form-fill-fields.tsx exports the identical evaluation logic
(isFieldVisible, duplicated rather than shared across the repo boundary) and filters fields live on every
render; PaginatedFormFillFields uses the same function to skip a page with zero currently-visible fields during
Back/Next navigation and on initial mount — but doesn’t re-scan the current page reactively while the visitor
is sitting on it (changing an earlier answer that would hide the current page doesn’t yank them off it
mid-view), and structural page count (progress bar, single-vs-multi-page chrome) is fixed at mount rather than
recomputed as pages become runtime-hidden. The admin field builder’s “Show this field only if…” control
(field-editor.tsx) lives in each field’s own state, reusing the exact fields.filter((f) => f.id && ...)
pattern the dedup/next-steps pickers already use; deleting a field client-side also proactively clears any other
field’s visibilityRule pointing at it, rather than letting the save round-trip fail with an “unknown field”
error.
Submitter response editing (Form.editableAfterSubmit, boolean, default true): member-only — a public/
anonymous submission carries no member identity to look one back up by, and there’s no login for an anonymous
visitor to come back through anyway, so the edit surface is unreachable for submitAsPublic regardless of this
flag. GET /forms/member/:id/submission (FormSubmissionService.getMySubmission) powers the member fill page’s
“you already submitted — edit it?” flow, reached from the DUPLICATE_SUBMISSION error submit already throws: it
returns the caller’s most recent submission for that form ({ submissionId, answers, editable }, most recent wins
when more than one exists — only possible when the form has no dedupField) via a composite (form_id, member_id)
index (IDX_form_submissions_form_id_member_id, since the base migration only ever indexed those columns
separately) with editable mirroring
Form.editableAfterSubmit, so the frontend can show a read-only “no longer editable” message instead of an edit
link without a second round trip. PATCH /forms/member/submissions/:submissionId (updateSubmission) re-runs the
exact same normalizeAnswers/validateAnswers pipeline a fresh submit does — 4a’s bounds and 4c’s
visibility-aware required-skipping both apply — but never calls notifyAdmins (an edit isn’t a new-submission
event), and only recomputes/rewrites dedupValueNormalized when the dedup field’s value actually changed. A 23505
conflict on save (the edited value collides with a different submission’s dedup value) is reported the same
DUPLICATE_SUBMISSION way a fresh submit’s own conflict is. Ownership is resolved entirely from the submission
record itself (submission.member.id === callerId) rather than trusting anything from the URL beyond the
submission id, matching how every other check in this module resolves from the form/member tokens. Both endpoints
additionally 404 on an ADMIN_ONLY form even when the caller happens to be the memberId attached to one of its
submissions (e.g. a baptism record an admin filed on the member’s behalf via submitAsAdmin’s optional memberId)
— those forms have no member-facing fill surface at all, and a subject shouldn’t be able to fetch or edit that
record just because they’re linked to it. A known, accepted limitation: editing a FILE answer to replace it does
not clean up the old file from Cloudinary — its FormFieldAttachment tracking row was already deleted at the
original submit time, and no resourceType is available at edit time to delete it correctly; judged too narrow an
edge case to justify redundantly storing resourceType in the answer shape or a fragile Cloudinary lookup.
FILE fields (upload-then-reference): the three submit endpoints stay pure JSON — a file is uploaded first, to
its own POST .../fields/:fieldId/attachment endpoint (member/public/admin variants, each gated by the same
visibility/audience-group rules as that audience’s own submit path, via FormSubmissionService.uploadAttachment),
which uploads to Cloudinary (form-submissions folder) and returns { url, publicId }. That object becomes the
FILE field’s answer in the normal submit call; validateAnswers checks only that it’s a well-formed {url, publicId} shape, not a format like EMAIL/NUMBER/DATE. Each upload also writes a FormFieldAttachment
tracking row (formId, fieldId, publicId, url, resourceType) — pure bookkeeping, not a real relation to
Form/FormField (plain UUID columns, deliberately no @ManyToOne, to avoid resurrecting the Form/FormField
cyclic-dependency issue documented on Form.fields for no benefit). On a successful submission,
saveSubmission deletes the tracking row for every FILE answer actually referenced (awaited, not fire-and-forget,
since a silent failure here would let the row survive to the sweep below and delete a file a real submission still
relies on). FormAttachmentCleanupScheduler (same forEachActiveTenant shape as SocialMediaRetentionScheduler)
sweeps nightly (0 4 * * *) for tracking rows older than 48h — a row’s mere continued existence past that window
is the signal the upload was abandoned, since a claimed one is deleted immediately — and deletes both the row
and the Cloudinary asset. Upload size is capped by the new MAX_FORM_ATTACHMENT_UPLOAD_MB platform setting
(DynamicLimitedFileInterceptor, same convention as cover/logo/class-material/finance-proof uploads).
| Method | Route | Auth | Notes |
|---|---|---|---|
| GET | /forms/audience-groups/lookup |
AdminGuard (FORMS_WRITE) | {id, name}[] of every Contact List, for the audience-restriction picker. Own route + gate rather than reusing GET /groups/lookup (gated on ANNOUNCEMENTS_WRITE) — a forms admin shouldn’t need a second, unrelated permission grant |
| GET | /forms/options |
AdminGuard (FORMS_READ) | Unfiltered, unpaginated {id, title, fields: {id, pageIndex}[]}[], for a “pick a form to embed” dropdown (Pages’ Registration-section editor) — a picker can’t paginate a single-select, so this stays “return everything” even though GET /forms below no longer does |
| POST | /forms |
AdminGuard (FORMS_WRITE) | Create a form with its fields in one call. Optional purpose/oneResponsePerMember/opensAt/closesAt/timeLimitMinutes/revealScoreImmediately (see Quiz + Voting / Time-boxing, above); a field’s correctOptions only takes effect when purpose: 'QUIZ' |
| GET | /forms?page=&limit=&search=&purpose=&visibility=&status= |
AdminGuard (FORMS_READ) | Paginated + filtered (FormService.listForms) — see below for why this changed from the earlier unpaginated find(). search is ILIKE across title/description; purpose/visibility are exact-match; status is ACTIVE|INACTIVE (maps to isActive). Response is PaginationResponseDto<Form>, not a bare array |
| GET | /forms/:id |
AdminGuard (FORMS_READ) | Get one form with fields |
| PATCH | /forms/:id |
AdminGuard (FORMS_WRITE) | Update form + diff-sync fields (see above). audienceGroupId/dedupFieldId/nextStepsFieldId/postSubmitMessage/generalActionUrl/generalActionLabel all follow the same “explicit null clears, omit to leave untouched” convention as eventId. postSubmitOutcomes follows it too, but replaces the whole array wholesale rather than diff-syncing per-outcome (see Ranked conditional overrides, above) |
| DELETE | /forms/:id |
AdminGuard (FORMS_WRITE) | Cascades fields + submissions |
| POST | /forms/:id/clone |
AdminGuard (FORMS_WRITE) | Clone a form — { title, ... } (see CloneFormDto; title is the only required field). Clone starts isActive: false with no cover/logo, fields copied verbatim with fresh ids, dedupField/nextStepsField re-matched by label. Never clones submissions |
| POST | /forms/:id/cover |
AdminGuard (FORMS_WRITE) | Multipart, field name cover. Sets Form.coverImageUrl |
| DELETE | /forms/:id/cover |
AdminGuard (FORMS_WRITE) | Clears the cover image |
| POST | /forms/:id/logo |
AdminGuard (FORMS_WRITE) | Multipart, field name logo. Sets Form.logoUrl |
| DELETE | /forms/:id/logo |
AdminGuard (FORMS_WRITE) | Clears the logo |
| POST | /forms/:id/submissions |
AdminGuard (FORMS_WRITE) | Admin records a submission on someone’s behalf — { answers, memberId? }. Works against any visibility, not just ADMIN_ONLY (e.g. backfilling a MEMBERS-visibility form entry for someone who called in). Returns { submissionId, nextSteps }, same shape as the member/public submit endpoints |
| GET | /forms/:id/submissions?sortBy= |
AdminGuard (FORMS_READ) | Paginated (?page=&limit=) — this list is attendance-scale, unlike the forms list itself. sortBy=score (QUIZ leaderboard) sorts score DESC NULLS LAST, createdAt DESC via a query builder instead of the default createdAt DESC |
| GET | /forms/:id/submissions/export |
AdminGuard (FORMS_READ) | CSV, one column per field (ordered), Submitted By shows the member’s name or “Public”. A FILE field’s cell is the uploaded file’s URL |
| GET | /forms/:id/analytics |
AdminGuard (FORMS_READ) | At-a-glance summary across all submissions, computed per field type (see below) |
| POST | /forms/:id/fields/:fieldId/attachment |
AdminGuard (FORMS_WRITE) | Multipart, field name file. Same shared upload path as the member/public equivalents below (see FILE fields, further down) — lets an admin attach a file while recording a submission via POST /forms/:id/submissions |
| GET | /forms/member |
JwtAuthGuard | Forms visible to the caller (isActive, MEMBERS or PUBLIC) — optional ?eventId= filter. A MEMBERS form with an audienceGroup is filtered out for anyone outside that Contact List |
| GET | /forms/member/:id |
JwtAuthGuard | Form fields + suggestedValues auto-filled from the caller’s own profile, plus windowState (OPEN/NOT_OPEN_YET/CLOSED), attempt ({startedAt, expiresAt}|null, a timed QUIZ’s in-progress attempt if any), and attemptCount (how many times this member has already submitted this form — pair with the returned form.oneResponsePerMember to show whether retakes are allowed). 404s (not 403) if the form has an audienceGroup the caller isn’t in |
| GET | /forms/member/history?purpose= |
JwtAuthGuard | The caller’s own past QUIZ/VOTE submissions, most recent first, across every form regardless of whether it’s still active — {formId, formTitle, formPurpose, submittedAt, score, maxScore, choice}[], paginated. Optional ?purpose=QUIZ|VOTE narrows it. Must be registered before :id or history would be swallowed as a form id |
| POST | /forms/member/:id/start |
JwtAuthGuard | Starts (or resumes) a timed QUIZ’s per-attempt countdown — FormAttemptService.startOrGetAttempt. Returns {startedAt, expiresAt}. 400 if the form isn’t a QUIZ with timeLimitMinutes set, or already completed with retakes disallowed |
| POST | /forms/member/:id/submit |
JwtAuthGuard | memberId comes from the token, never the body. Returns { submissionId, nextSteps, score?, maxScore?, scorePendingUntil? } — the score fields are QUIZ-only (see Quiz + Voting, above) |
| GET | /forms/member/:id/submission |
JwtAuthGuard | The caller’s own most recent submission for this form — { submissionId, answers, editable, score?, maxScore?, scorePendingUntil? }. Powers the “edit your response” flow off a DUPLICATE_SUBMISSION error, and reveals a withheld QUIZ score once its window has closed. 404s on an ADMIN_ONLY form even for a linked member |
| PATCH | /forms/member/submissions/:submissionId |
JwtAuthGuard | Edit the caller’s own submission — { answers }, same shape as submit. 400 if Form.editableAfterSubmit is off; 404 if the submission isn’t the caller’s or the form is ADMIN_ONLY |
| POST | /forms/member/:id/fields/:fieldId/attachment |
JwtAuthGuard | Multipart, field name file, max size MAX_FORM_ATTACHMENT_UPLOAD_MB. Returns { url, publicId } — the answer value for a FILE field in the submit call above. Subject to the same MEMBERS/PUBLIC visibility + audience-group gating as submit |
| GET | /forms/public/:id |
Public, 404 unless isActive && visibility === PUBLIC |
No tenant subdomain restriction beyond the usual Host-header resolution. Response is a sanitized PublicFormDto — every field’s optionMetadata is stripped |
| POST | /forms/public/:id/submit |
Public, rate-limited (5/min) | memberId is always null — an open, unauthenticated write endpoint, throttled from day one rather than retrofitted. Returns { submissionId, nextSteps } |
| POST | /forms/public/:id/fields/:fieldId/attachment |
Public, rate-limited (5/min) | Multipart, field name file. Same upload-then-reference contract as the member endpoint, no member identity involved |
forms is a toggleable module (KNOWN_MODULES, ModuleEnabledGuard) and Pro-plan-gated
(@RequiresPlan(PlanFeature.FORMS), PlanGuard) on all three controllers, including the public one — PlanGuard
keys off the tenant resolved by TenantMiddleware, not the caller’s auth, so an unauthenticated visitor filling out
a public form on a Free-tier tenant is still correctly blocked. Both gates are independent: a Pro tenant can still
disable Forms via the module toggle, and a Free tenant sees 403 PLAN_UPGRADE_REQUIRED regardless of the module
toggle’s state.
Analytics (GET /forms/:id/analytics, a Google-Forms-style summary, not raw rows): computed in-memory per
field from every submission’s answers[fieldId], shaped by that field’s type — DROPDOWN/CHECKBOX get a
per-option {count, percentage} breakdown (CHECKBOX counts every selected value, since one submission can pick
several options); NUMBER gets {average, min, max}; FILE gets {uploadCount} (same number as
responseCount — a {url, publicId} answer isn’t a meaningful “sample” the way free text is); every other type
(TEXT, EMAIL, PHONE, TEXTAREA, DATE) gets up to the 20 most recent non-blank answers as sampleAnswers,
since there’s no meaningful aggregate for free text. Blank/null/undefined answers are excluded from
responseCount and every computation — a field added after some submissions already exist doesn’t drag its
stats toward zero.
GET /forms moved from an unpaginated client-filtered list to server-side pagination/search (FormService. listForms), reversing an earlier decision recorded in this doc. The original reasoning (admin-authored reference
data like departments/event-configs doesn’t grow unboundedly, so backend pagination just trades an instant
zero-network filter for a round-trip per keystroke) held for Forms before Quiz + Voting — but a QUIZ/VOTE
form is created far more often than a STANDARD one ever was (a weekly sermon quiz, a recurring vote), so Forms
now behaves like games/volunteer_opportunities/small_groups (which already made this same move — see
their own admin-list search/filter) rather than like departments. search is ILIKE '%term%' across title/
description, backed by IDX_forms_title_trgm/IDX_forms_description_trgm (same trigram-index pattern as those
three modules); purpose/visibility/status are exact-match, backed by IDX_forms_purpose and the
pre-existing idx_forms_visibility/idx_forms_is_active. app/forms/page.tsx’s search box and Visibility/
Status/Purpose filters now debounce and re-fetch page 1 server-side (matching app/games/page.tsx’s own
debounce pattern) instead of filtering an already-fetched array, and the list gained a PaginationBar.
A form picker can’t paginate a single-select, though — Pages’ Registration-section editor (sections-editor. tsx) still needs every form to populate its dropdown. GET /forms/options (FormService.getFormOptions,
useFormOptions() in discuva-admin) exists for exactly that: unfiltered, unpaginated, and deliberately lighter
than a full FormRecord (just {id, title, fields: {id, pageIndex}[]}, enough for the picker’s own
formPageCount warning) so it stays cheap to fetch on every Pages-editor load regardless of how large Forms
grows.
discuva-admin UX: a “More Options” disclosure on each field. A client-side addition — app/forms/ field-editor.tsx’s per-field editor gained a collapsible “More Options” section (helper text, length/selection
bounds, the validation pattern, and the conditional-visibility rule) — collapsed by default with a small dot
indicator when a field already has any of that configured, so a form with several fields doesn’t turn into a
long scroll of mostly-unused optional settings. The field’s actual content (label, type, and its options for
DROPDOWN/CHECKBOX) stays always visible — only the advanced/optional settings collapse.
Filter <select> styling, reported live as looking out of place — the Visibility/Status filters initially
used the browser’s native <select> chevron, which clashed against the custom-styled search box right next to
it. Every other <select> elsewhere in discuva-admin uses appearance-none to strip that native arrow, but
none of them replace it with anything, leaving a box with no visible dropdown indicator at all — not a pattern
worth copying as-is. Fixed with appearance-none plus an actual ChevronDown icon positioned absolutely inside
a wrapping relative div (pointer-events-none so it doesn’t intercept the click) — a small, deliberate
improvement on the app-wide convention rather than a match to it, applied here and to the equivalent filter on
the Pages list below.
Pages (src/pages/)
Per-church public web pages — a homepage or a shareable landing page (e.g. a conference page), assembled from a
fixed library of section types rather than a free-form drag-and-drop canvas, mirroring the Forms builder’s own
“admin assembles typed, ordered items” shape. A Page has a unique-per-tenant slug (url-safe, ^[a-z0-9-]+$),
a title, an isPublished flag (only a published page is ever reachable publicly — an unpublished draft 404s
identically to an unknown slug, so a visitor can never distinguish “never existed” from “not live yet”), optional
seoDescription/ogImageUrl for link-preview metadata, and an ordered sections: PageSection[] ({ id, type, content }, plain jsonb, whole-array replace on every save — same convention Form.postSubmitOutcomes uses,
since there’s no per-section DB row to diff against). id is client-generated (a uuid), not server-assigned —
sections have no relation of their own for TypeORM to assign an id to.
Draft/publish split (AddPageDraftFields1796540400000) — title, seoDescription, ogImageUrl/
ogImagePublicId, and sections each have a draft* counterpart (draftTitle, draftSeoDescription,
draftOgImageUrl/draftOgImagePublicId, draftSections). PageAdminController.update (PATCH /pages/:id)
writes only the draft* columns for these four fields — an already-published page can be edited freely, any
number of times, without a single PATCH changing what a visitor sees. slug and isPublished are the
exception: both keep writing their live columns immediately, same as before PATCH gained this split (slug is a
URL/identity concern, not content; isPublished is a reachability switch, not content either). POST /pages/:id/publish (PageService.publish) is the only thing that copies draft* onto the live columns — it
also sets isPublished = true, so it doubles as “publish this for the first time” and “push a pending edit
live” in one action. create() still sets live and draft fields to the same submitted values, since nothing
live exists yet to protect for a brand-new page.
The one correctness-sensitive detail: setOgImage/removeOgImage (POST/DELETE /pages/:id/og-image) now
write draftOgImagePublicId, and only delete the replaced Cloudinary asset when that replaced id isn’t also
the current live ogImagePublicId — otherwise uploading a new draft image would delete the asset the
published page still points at, before that draft was ever published. publish() does the mirror-image cleanup:
after copying draft onto live, it deletes the previous live OG image from Cloudinary if publishing actually
swapped it for a different one (safe there — nothing else can be pointing at it once that save commits).
Shareable preview links — every Page also has a previewToken (character varying UNIQUE, DB-default
gen_random_uuid()::text, generated once per page and never rotated in v1). GET /pages/public/:slug/preview?token=... (PageService.getForPreview) returns the same PublicPageDto shape as
the live public route, sourced from the draft* fields instead — no isPublished check, so a page that’s never
been published at all is still previewable. A missing/wrong token 404s identically to an unknown slug, same
“don’t reveal which reason” posture the live route already takes for unpublished-vs-nonexistent. The token is
the only gate: this is a bearer-link model (discuva-admin’s “Preview” button builds
<liveUrl>?previewToken=<token>), not an authenticated one — anyone holding the link can view the draft, same
tradeoff a Figma/Google Docs “anyone with the link” share carries.
Page-level theme + accent/background color (AddPageThemeFields1796713200000,
AddPageBackgroundColor1796886000000) — theme (character varying, default 'minimal'), accentColor, and
backgroundColor (both nullable hex strings), each with a draft* counterpart following the exact same
draft/publish routing as every other content field above: PATCH writes draftTheme/draftAccentColor/
draftBackgroundColor only, publish() copies all three onto the live columns. theme is a whole-page choice,
not per-section — 'minimal' is the original look (unchanged) and every page defaults to it; 'bold' is a
church-picked background (not a fixed color — the original single hardcoded dark background was a real gap for
a multi-tenant product, where every church has its own brand) with bold/uppercase headings. backgroundColor
null falls back to the original default (#150a08, discuva-member’s bold-theme.ts DEFAULT_BOLD_BG), and is
only meaningful under 'bold' — there’s no analogous concept under 'minimal''s plain white background, so
discuva-admin’s editor keeps the backgroundColor picker gated behind theme === 'bold' and nulls it on save
otherwise. accentColor, by contrast, applies under either theme — a 'minimal' (light) page can pick a
brand accent color too (used more sparingly there: stat numbers, the active FAQ question, buttons, the Speakers
“Host” badge, Countdown digits — there’s no dark background for it to stand out against the way it does under
'bold'). This wasn’t always true: accentColor used to be gated behind theme === 'bold' in both
discuva-admin’s editor (hidden picker, nulled on save) and discuva-member’s renderer (dark && guards in
section-renderer.tsx/faq-accordion.tsx/countdown-timer.tsx) — a real gap for a multi-tenant product, since
a church running the default light look had no way to apply its brand color anywhere. Both colors validated at
the DTO layer only (@IsIn(['minimal', 'bold']), @IsHexColor() ×2), not in assertValidSections — neither is
per-section content.
Rendering lives entirely in discuva-member (bold-theme.ts + SectionRenderer): resolveBoldPalette computes a
full set of CSS custom properties (--bold-bg, --bold-fg, --bold-fg-NN at several opacity steps,
--bold-border-NN) from backgroundColor once, applied as an inline style on the page’s outer wrapper
(app/p/[slug]/page.tsx) — foreground text color is never stored, it’s picked (white vs. near-black) from the
background’s YIQ luminance so whatever a church picks stays legible without them needing to reason about
contrast themselves. Every section references these by name (text-[var(--bold-fg-60)], bg-[var(--bold-bg)])
instead of hardcoding text-white/text-white/60 — Tailwind compiles that class fine at build time (the class
string never changes, only what the variable resolves to), which is what lets one church-picked color cascade
into every section with no prop threading. accentColor remains a real runtime value applied via inline
style={{ color / borderColor: accentColor }} (a literal var() string can’t be used there since it’s an
admin-controlled hex, not a fixed CSS variable) — never a Tailwind bracket class, since Tailwind’s JIT can’t
statically extract a class from a value only known at render time.
Page-level font (AddPageFontFamily1796972400000) — fontFamily/draftFontFamily (nullable character varying), same draft/publish routing as theme/accentColor/backgroundColor: PATCH writes draftFontFamily
only, publish() copies it onto the live column. Validated against a small curated list, PAGE_FONTS = ['inter', 'poppins', 'playfair', 'bebas-neue'] (@IsIn, not free text) — next/font/google needs a statically-imported
specifier to self-host/preload a font, which rules out an arbitrary runtime string the way accentColor’s hex
value works; a short fixed list sidesteps that entirely. Page-level only, not per-section, for the same reason a
page-builder that let every block pick its own typeface would look amateurish rather than flexible. null keeps
the exact font-sans look every page already had — a font only ever applies once a church explicitly picks one
from discuva-admin’s Font <select> (app/pages/page.tsx, next to Theme); there’s deliberately no per-theme
default that would silently change an already-published page’s typography as a side effect of this feature
shipping. Rendering (discuva-member’s components/pages/page-fonts.ts) statically imports all 4 fonts via
next/font/google and picks one’s .className (not the CSS-variable .variable pattern app/layout.tsx’s own
root fonts use — a page needs exactly one font applied to its whole subtree at a time, so there’s no need for
several fonts coexisting via CSS variables) onto the page’s outer wrapper in app/p/[slug]/page.tsx, overriding
the inherited body font via normal CSS specificity. Bebas Neue only ships at weight 400 on Google Fonts, so a
section elsewhere requesting font-bold under it renders at that same weight (a browser fallback, not a bug).
Per-section style overrides (SectionStyleDto, PageSectionDto.style) — each section in sections/
draftSections may carry an optional style: { align?, columns?, size?, accentColor?, spacing?, layout? }
sibling to content, validated as a real nested class (@ValidateNested() + @Type(() => SectionStyleDto))
rather than a bare @IsObject() the way content is — unlike content, whose shape depends on type, style’s
shape is fixed regardless of section type, so a shared DTO validates it directly instead of going through
PageService.assertValidSections’s per-type switch. align ∈ ['left', 'center', 'right'], columns ∈
[1, 2, 3, 4], size ∈ ['sm', 'md', 'lg', 'xl'], accentColor a hex string overriding the page-level one for
just that section, spacing ∈ ['sm', 'md', 'lg'], layout ∈ ['stacked', 'split']. Validated structurally
only — this DTO does not know or enforce which fields apply to which PageSectionType; that per-type
applicability table is owned entirely by discuva-admin’s SECTION_STYLE_APPLICABILITY
(app/pages/sections-editor.tsx), kept in exactly one place to avoid two authorities drifting out of sync.
spacing is the one field with no applicability gating at all — it applies to every section type. layout is
REGISTRATION-only today:
| Type | align | columns | size | accentColor |
|---|---|---|---|---|
| HERO | text block | — | subtitle body text | CTA button |
| ABOUT | stacked layout only (meaningless once layout: 'split' already anchors text to one side) |
— | body text (applies in both stacked and split — body copy exists in both) | — |
| STATS | — | 1–4 | value text size | value color |
| SPEAKERS | — | 1–4 | photo tile size (independent of columns — caps the tile’s own footprint, not how many share a row) |
host badge + regular-tile badge |
| SCHEDULE | — | 1–4 | — (day cards are information-dense; a shrink knob risks overflow) | label/icons |
| REGISTRATION | stacked: heading/body text + the white form card’s own position. split: which side the form card sits on (see layout below) — either way the card’s contents (EmbeddedFormFill) stay untouched |
— | body text (both stacked and split) | — (the embedded form card is deliberately theme-independent; out of scope) |
| TESTIMONIALS | — | 1–3 (not 4 — a quote card needs real width) | quote text | — |
| FAQ | — | — | heading + question/answer text, scaled together as one choice | active question |
| MERCH | image+CTA block (coupled with size — alignment is only visible once size caps the image narrower than the section) |
— | image width cap | CTA button |
| COUNTDOWN | row justify | 1–4 | digit size | digit color |
| FOOTER | text block | — | footer text | links/social links |
| GALLERY | — | 1–4 | — | — |
| CHURCH_CALENDAR | — | — | — | entry card border/icons |
| LIVE_NOW | row justify (centers the live badge / offline text) | — | — | live badge border |
spacing — vertical breathing room above/below a section, universal across every type (added after real user
feedback: “the space between the form and the stats counter is too much,” and a request that it be
customizable, not just globally reduced). Unlike the other 4 fields, this isn’t per-type-gated at all —
discuva-admin’s SectionStyleControls renders the Spacing control unconditionally on every section card, and
SectionStyleControls itself can no longer return null for that reason. sm/md/lg map to py-8/py-16/
py-24 (discuva-member’s spacingClass, components/pages/section-style.ts) — md (py-16) is the exact flat
value every section used before this knob existed, so an unset/omitted spacing renders byte-for-byte identical
to every already-published page. HeroSection is the one exception — its vertical rhythm is driven by
aspect-video (when it has a background image) or a responsive py-6 sm:py-12 md:py-16 content overlay (when it
doesn’t), neither of which is a flat padding value a spacing knob could meaningfully replace, so Hero doesn’t
carry this knob.
SECTION_PADDING_X — every section’s own horizontal gutter (components/pages/section-style.ts): px-6 sm:px-8 lg:px-12, replacing a flat px-6 that every section used at every breakpoint. Fine on a narrow phone
(24px), but on a ~1024px-wide tablet the same flat 24px read as almost no margin at all relative to how wide the
content block actually is — confirmed against a real screenshot of the yfc-2026 page, text running close enough
to the viewport edge to look unfinished. Not exposed as a per-section style knob like spacing — there’s no
scenario where an admin would want a different gutter on one section than the rest, so this is one shared,
unconditional constant (SECTION_PADDING_X) every section pulls from, not a lookup keyed by a field on
SectionStyle. HeroSection keeps its own separate px-4 sm:px-6 content-overlay padding (the no-image case) —
a deliberately different, narrower value from before this fix, left untouched since it wasn’t the pattern flagged.
Hero’s mobile sizing, below sm (640px), with a background image: min-h-[85vh] sm:min-h-0 sm:aspect-video,
not a flat aspect-video at every width. A portrait phone viewport crops a 16:9 box down to a short, squat strip —
confirmed against the real yfc-2026 page next to a reference site’s immersive full-screen mobile hero, on a
430×932 viewport matched to the user’s own DevTools screenshot. sm:min-h-0 clears the min-height back out at
sm: and up so the original aspect-video behavior takes over unopposed on tablet/desktop, unchanged. The
no-image case (py-6 sm:py-12 md:py-16 content overlay) is untouched — this only affects Hero sections with a
background image set.
backgroundImageUrlMobile (HeroContent.backgroundImageUrlMobile, structurally validated the same as
backgroundImageUrl in assertValidSections, no dedicated migration — it’s a sibling key inside the existing
jsonb content, not a typed column) — an optional portrait variant of the Hero background image, used only
below sm. min-height is driven purely by viewport height, decoupled from width, so on a tall narrow phone
min-h-[85vh] pushes the box into a much taller/narrower aspect ratio than a landscape backgroundImageUrl
actually has, forcing object-cover to crop hard off the sides. A dial-back to min-h-[65vh] was tried first to
reduce the crop, and did — verified via screenshot, the title text was no longer clipped — but the user weighed in
that logos elsewhere in the same flyer image were still being cropped out, and preferred keeping the fuller 85vh
immersive height with a real fix for the image itself. backgroundImageUrlMobile is that fix: when set,
discuva-member renders it (sm:hidden) instead of backgroundImageUrl below sm, and backgroundImageUrl
(hidden sm:block) above it — two <Image fill priority> elements rather than one, so mobile fetches only the
image actually shown at that width… except both are still requested eagerly regardless of which one is visible,
since CSS display: none doesn’t stop the underlying <img> tag’s own fetch the way a real <picture>/<source media> swap would — a deliberate simplicity tradeoff over building true conditional fetching, revisit if this
page’s LCP becomes a real concern.
First cut of backgroundImageUrlMobile still used min-h-[85vh] for its box, and still cropped. A real
1080×1350 (4:5) upload was still cropped ~16% off each side on a 430×932 phone — min-h-[85vh] (792px tall) is a
narrower/taller box (aspect ≈0.54) than a 4:5 image (0.8) regardless of which image fills it, since min-height
is a viewport measurement with no relationship to the uploaded image’s own dimensions. Confirmed the fix has to be
about the box, not the image: below sm, once backgroundImageUrlMobile is set, the section now sizes itself
via aspect-[4/5] instead of min-h-[85vh] — the box’s shape comes from the image’s own ratio, so a correctly
proportioned upload renders edge-to-edge with zero crop on any phone width, not just the one it happened to be
tested against. sm:aspect-video still takes over unopposed at tablet/desktop. Verified against the real
yfc-2026 page’s own uploaded 1080×1350 image at 430×932: measured section box was exactly 430×537.5 (ratio
0.800, matching 4:5 to three decimal places) and the screenshot showed both corner logos and all text rendering
completely uncropped. Falls back to the original min-h-[85vh]/single-image/object-cover behavior whenever
backgroundImageUrlMobile is unset — no change for any page that hasn’t set one. discuva-admin’s Hero editor
(sections-editor.tsx) states the 4:5 ratio as a requirement, not a suggestion, in its upload hint — since the
box now takes its shape directly from it, an off-ratio upload is the one remaining way to still get cropped.
Needs @ValidateNested()/@Type() specifically because the global ValidationPipe’s whitelist: true
(main.ts) would otherwise silently strip a plain object literal here before validation even runs;
forbidNonWhitelisted: true means an unrecognized key inside style (e.g. a typo) 400s rather than being
silently dropped. Stored as an opaque jsonb sibling to content on the entity side (Page.sections’s
PageSection.style), requiring no migration of its own since sections already live in a jsonb array.
Rendering (discuva-member, components/pages/section-style.ts + SectionRenderer) — a section’s own
style.accentColor, when set, wins over the page-level accentColor for just that section
(SectionRenderer’s effectiveAccentColor). columns maps to a columnBasisClass(columns, variant) lookup —
three separate literal-string tables (compact for Stats/Countdown, roomy for Schedule, square for Speakers’
override path), each a Tailwind-JIT-safe literal string (never built via runtime interpolation, since Tailwind’s
JIT only scans literal strings present in source). Each entry pairs an unprefixed min-w/max-w hint (today’s
original, content-driven mobile wrapping, unchanged) with an sm: override that actually forces the requested
column count from 640px up — sm:min-w-0 sm:max-w-none clear the mobile hint, sm:grow-0 sm:shrink-0 sm:basis-[calc(...)] (or sm:basis-full for columns: 1) sets a fixed, non-growing width equal to exactly
1/columns of the row (minus that row’s own gap, split proportionally) — flexbox decides how many items share a
line using this basis before grow/shrink is applied, which is what makes this a real guarantee rather than a
hint. (A first version used only the unprefixed min-w/max-w hint and shipped looking correct in isolation, but
real-browser screenshot verification caught that it doesn’t actually cap items per row — e.g. a “columns: 2”
Stats row rendered all 4 stats on one line at tablet/desktop width, because a min-width hint alone never stops
extra items from sharing a line once the container is wide enough.) size always replaces whatever
count-based auto-sizing a section already had (Stats’ statValueSizeClass), never blends with it, via a
responsive class pair per bucket (e.g. xl → text-5xl sm:text-7xl) — Countdown has no existing auto-sizing to
preserve, so its size is a plain override. Speakers’ size (tile footprint) and columns (row-sharing cap)
are independent and combinable — a fixed-size tile still respects a columns cap on its wrapper, they aren’t
mutually exclusive. align maps to text-left/center/right (block sections) or justify-start/center/end
(Countdown’s row). Testimonials’ columns (1–3) maps to a plain CSS Grid instead of flex-wrap — quote cards are
block-shaped and don’t have the “partial last row” centering concern the flex-wrap sections solve for.
Speakers’ flex-wrap migration is opt-in, not a default-path change — the existing grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 is an explicit named-breakpoint contract every Speakers section without a style
override still relies on; flex-wrap’s wrap point is driven by cumulative width math, not named breakpoints, and
isn’t guaranteed to reproduce the same 2/3/4 cadence. Rather than risk regressing every existing section, the
grid stays byte-for-byte untouched whenever neither columns nor size is set; flex-wrap only activates once a
page actually opts into an override — new behavior nobody currently depends on. Verified via real Playwright
screenshots at 375px/768px/1280px against a live throwaway Docker stack (DB-backed, not simulated).
Hiding a section without deleting it (PageSectionDto.hidden) — each section may carry an optional
hidden?: boolean sibling to content/style, validated as a plain @IsOptional() @IsBoolean() (no per-type
meaning, unlike style). A hidden section stays fully saved — content, style, its position in the list — but
PageService.getForPublic/getForPreview both filter it out of the sections array they return
(withoutHiddenSections, applied after withApprovedTestimonials), so discuva-member never receives it at all
and needs no changes of its own to honor this. Filtering happens in both routes, not just the live one — a
preview that showed a section publishing would actually hide wouldn’t be previewing the real outcome. Distinct
from removing the section outright (sections.filtering it out of the array client-side): a church building out
next month’s section ahead of time, or temporarily pulling one down, keeps its content and doesn’t need to
rebuild it from scratch later. PageAdminController’s own GET /pages/:id (the builder’s raw read) is
unaffected — it returns every section, hidden or not, since the admin needs to see and un-hide them.
discuva-admin’s SectionsEditor renders an eye/eye-off toggle per section card (dimmed + a “Hidden” badge when
on) — no separate confirm dialog, unlike removing a section.
Duplicating an existing page (POST /pages/:id/duplicate, PageService.duplicate) — starts a brand-new page
from an existing one’s current draft (not live — the most up-to-date working version, same reasoning
discuva-admin’s openEdit always continues from draft*). Takes a required new slug (pages have no natural
“copy” slug to auto-generate, and slugs must stay unique) and an optional title override, falling back to
"<source title> (Copy)". Always starts unpublished, regardless of the source page’s own isPublished state
— a duplicate under a fresh, unreviewed slug must never silently go live just because the page it was copied from
happened to be. Sections are deep-cloned (JSON.parse(JSON.stringify(...))), not shared by reference — the two
pages are genuinely independent from the moment of creation, and re-validated (assertValidSections) before
saving, the same defensive check publish() already applies, in case something the draft references (e.g. a
REGISTRATION section’s formId) was deleted since the source was last saved. Neither OG image is carried
over — sharing one Cloudinary asset’s public_id across two Page rows would let either page’s own
image-replace/remove flow delete an asset the other still points at; the duplicate simply starts with no OG
image, same “no orphan-cleanup, accepted simplicity” tradeoff already made elsewhere in this service.
previewToken is not copied either — the DB default on the new row generates its own, since every page needs an
independent, private preview link. discuva-admin surfaces this as a “Duplicate Page” button in the editor panel
(prompts for the new slug, then opens the created page for editing) — see handleDuplicate in app/pages/page.tsx.
Section toolkit (PageSectionType, 14 fixed types) — content’s shape depends on type:
| Type | Content shape |
|---|---|
HERO |
title, subtitle?, dateRangeText?, backgroundImageUrl?, ctaLabel?, ctaUrl? (ctaLabel/ctaUrl paired — both or neither) |
ABOUT |
heading, body, imageUrl?, layout? ('stacked' | 'split'), imagePosition? ('left' | 'right'). layout defaults to 'stacked' (image above centered text, unchanged); 'split' is a two-column layout (text one side, image filling the other — falls back to 'stacked' client-side if there’s no image to split against). imagePosition only matters when layout is 'split', defaulting to 'right' |
STATS |
items: { label, value }[] (≥1) |
SPEAKERS |
heading?, items: { name, title?, photoUrl?, isHost? }[] (≥1). At most one item should be isHost: true — rendered as a larger, featured card above the regular grid instead of inside it (not validated server-side, same “harmless if malformed” precedent HERO’s hideOverlayText sets: if more than one item claims it, only the first counts, the rest fall back into the grid) |
SCHEDULE |
heading?, days: { label, date?, venue?, entries: { time?, title }[] }[] (≥1 day, each with ≥1 entry). venue is per-day, not per-section — a multi-day programme can move locations day to day. Rendered as a bordered card grid (discuva-member’s ScheduleSection), one card per day |
REGISTRATION |
heading?, body?, formId, ctaLabel?, hideFormMeta? — embeds an existing Form inline (rendered client-side via the same FormFillFields/PaginatedFormFillFields components a form’s own public fill page already uses) rather than reimplementing registration. Reuses the whole Forms feature (validation, dedup, notifications, postSubmitOutcomes) for free. hideFormMeta (default off) suppresses the linked form’s own name/description inside the card — see the embedded-form-fill fixes below. A page may carry more than one REGISTRATION section (e.g. event registration and a separate merch pre-order form) — nothing restricts it, each is independent with its own heading/body/formId |
TESTIMONIALS |
heading?, items: { quote, name?, photoUrl? }[] (≥1), acceptSubmissions?: boolean. When acceptSubmissions is on, a visitor can submit their own testimony from the public page (see “Visitor-submitted testimonials” below) — approved ones are merged into items server-side, so items returned by the public/preview routes may be longer than what was saved |
FAQ |
heading?, items: { question, answer }[] (≥1) |
MERCH |
heading?, imageUrl, linkLabel?, linkUrl? (linkLabel/linkUrl paired — both or neither) — a single promotional image/poster plus an optional CTA link, e.g. a merch flyer or a pre-order banner |
COUNTDOWN |
heading?, targetDate, expiredMessage? — a live days/hours/minutes/seconds count down to targetDate, the one section whose content carries a real machine-readable instant rather than free text (unlike HERO.dateRangeText/SCHEDULE’s day labels). targetDate must be a value Date.parse accepts (rejected with a 400 otherwise); discuva-admin’s editor captures it via a datetime-local input and converts it to a full ISO instant with new Date(local).toISOString() at the moment it’s picked, so the stored value is timezone-correct for every visitor with no backend timezone plumbing needed — see that repo’s CountdownContent comment. Rendering (discuva-member’s CountdownTimer) is a "use client" island using useSyncExternalStore (not useState+useEffect) to tick a 1s setInterval against Date.now(), with getServerSnapshot returning null so SSR renders nothing rather than baking in the server’s clock and mismatching on hydration |
FOOTER |
heading?, text?, links?: { label, url }[], socialLinks?: { platform, url }[], showCopyright?, showContactInfo?. platform ∈ FOOTER_SOCIAL_PLATFORMS ('instagram' | 'facebook' | 'youtube' | 'tiktok' | 'x' | 'website') — a small fixed set, not free text. showCopyright defaults to true (the church name + current year); showContactInfo defaults to false (the tenant’s own address/supportEmail, never typed per page — see church below). See “Every page gets a footer” below for why this type is optional and how it interacts with the automatic default |
GALLERY |
heading?, images: { url, publicId?, caption? }[], syncFolderUrl? — a responsive image grid. images is authored directly (like SPEAKERS.items) and required (≥1) when syncFolderUrl is unset; optional/ignorable when it is set, since PageService.withSyncedGalleryImages overwrites it at render time — see “Gallery folder sync” below. Free on any tenant with the pages module enabled, no plan gate |
CHURCH_CALENDAR |
heading?, calendarId — references an existing ChurchCalendar by id (must exist and be isPublished, checked at save time). Does not re-author entries: PageService.withChurchCalendarEntries merges that calendar’s live title/theme/accentColor/entries into content server-side on every getForPublic/getForPreview call, the same “reference another entity, merge its data server-side” pattern REGISTRATION.formId already uses for embedding a Form. Paid-plan gated on PlanFeature.CHURCH_CALENDAR — see “Church Calendar section — plan gating and downgrade handling” below |
LIVE_NOW |
heading?, offlineMessage? — no content to author beyond these two; isLive/sessionInfo are computed fresh on every request (PageService.withLiveStatus, backed by ServiceSessionService.getActiveSessions()), never authored or cached. Free, no plan gate |
Gallery folder sync — “a specified path, just update pictures, always up to date.” GalleryContent. syncFolderUrl (optional) is a public Google Drive folder link — when set, PageService. withSyncedGalleryImages (GalleryFolderSyncService) lists that folder’s images at request time and
overwrites images in the getForPublic/getForPreview response only, never written back to the saved
section, so an admin can keep dropping new photos into the folder each week instead of re-uploading through
the Gallery editor. Deliberately the “no OAuth, no connected account” shape the church calendar/social-media
integrations in this codebase don’t use: a single platform-wide, read-only GOOGLE_DRIVE_API_KEY env var
(not a per-tenant credential, and not TenantYoutubeIntegration’s per-tenant-stored-key pattern) — the folder
itself just needs “Anyone with the link can view” sharing turned on, no admin authorizes anything. Every
failure mode degrades gracefully rather than breaking the page: extractFolderId (a plain
/folders/([a-zA-Z0-9_-]+)/ regex, no live API call) is checked at save time so an admin can save/edit a
syncFolderUrl even with no API key configured yet; at render time, an unset GOOGLE_DRIVE_API_KEY, a
malformed url, a folder that isn’t actually public, a Drive API error, or a network failure all come back as
[] from GalleryFolderSyncService.listImages and the section falls back to whatever images are still
saved (a visitor never sees a broken or empty gallery just because a sync attempt had a bad day). Listings are
cached 10 minutes per folder id (CacheService.getOrSet, key gallery_folder_sync:<folderId>) — long enough
to spare the one shared API key’s quota across every visitor of every tenant using this, short enough that
photos swapped in before Sunday service show up the same morning.
Church Calendar section — plan gating and downgrade handling. CHURCH_CALENDAR is the one section type gated
behind PlanFeature.CHURCH_CALENDAR — a service-level check (PageService.assertChurchCalendarEntitled/
isChurchCalendarEntitled, resolving the tenant’s plan via PlanFeatureResolverService.resolve(tenantId), the
CLS-sourced tenantId the exact same pattern finance-request.service.ts’s own service-level plan check already
uses), not a controller-level @RequiresPlan guard — a single jsonb section type living inside a mixed array of
otherwise-unrestricted sections can’t be gated by a route-level decorator the way a whole endpoint can. The check
runs in two places, for two different reasons:
- At save time (
assertValidSections, alongside thecalendarIdexistence/published check): a non-entitled tenant’s save attempt throwsForbiddenException({message, code: 'PLAN_UPGRADE_REQUIRED', requiredFeature: PlanFeature.CHURCH_CALENDAR})— the exact shape discuva-admin’s axios interceptor already watches for (utils/auth/axios-client.ts) to pop the existing upgrade-required modal, so no new frontend upsell UI was needed for this at all. - At read time (
getForPublic/getForPreview, viawithChurchCalendarEntries): a section saved while the tenant was entitled does not keep working forever after a downgrade. If no longer entitled, the section is dropped from the output array entirely — never returned broken or half-filled — so a visitor never sees a permission error or an empty/awkward gap; the page simply renders as though the section weren’t there. The section’s own savedcontent(itscalendarId,heading) is never touched by this — purely a response-time filter — so resubscribing makes it reappear automatically with zero reconfiguration. The same re-check also covers the referencedChurchCalendaritself losing itsisPublishedflag after the section was saved — either condition failing drops the section the same way.
The admin builder’s own reads (GET /pages and GET /pages/:id, via PageService.getAll/getByIdForAdmin)
are deliberately not filtered this way — an admin editing their own page should never have a section silently
vanish from their own view of it. Instead both responses carry a churchCalendarEntitled: boolean (the same
tenant-wide value attached to every row getAll returns, since entitlement isn’t per-page) so discuva-admin’s
editor can show a small “Requires upgrade — not currently visible to visitors” badge directly on that section’s
card. discuva-admin opens a page for editing straight from the GET /pages list’s own in-memory array, never a
separate per-id fetch — which is why getAll carries the flag too, not only getByIdForAdmin.
HERO and FOOTER are capped at one per page (PageService.assertNoDuplicateSingletonSections, called at the
end of assertValidSections) — unlike REGISTRATION (documented below as deliberately unrestricted, e.g. event
registration alongside a separate merch pre-order form) or any other content-block type, where a second instance
is a real, legitimate use, a second opening banner or a second footer doesn’t represent anything — a page has
exactly one of each, by what they are. Enforced in two places: discuva-admin’s own “Add Section” picker grays
the option out once one already exists (SINGLETON_SECTION_TYPES in sections-editor.tsx), and this server-side
check is defense-in-depth for the API being hit directly (a 400, not a silent drop).
Every page gets a footer, whether or not it has a FOOTER section — a direct feature request (“can we introduce
a footer?”). PublicPageDto/the preview DTO both gained a church: { name, address, supportEmail } field
(PageService.resolveChurchInfo), the current tenant’s own info resolved via ClsService<AppClsStore> +
tenantRepo.findOneBy, sharing the same tenant-branding:${tenantId} cache entry EmailQueueService/
PdfService/TenantCurrencyService already populate (see TenantCurrencyService’s own comment on that shared
key) — a fourth call site of the same established pattern, not a new abstraction. PagesModule gained a plain
TypeOrmModule.forFeature([Tenant]) for this (Tenant is a public-schema, control-plane entity, same reasoning
UtilityModule’s own registration of it documents — not TenantTypeOrmModule). No tenant CLS context (shouldn’t
happen for a real request here) falls back to the CHURCH_NAME env default with no address/email, mirroring
TenantCurrencyService’s own defensive fallback.
discuva-member’s app/p/[slug]/page.tsx renders whichever FOOTER section(s) a page has (if any) pinned to the
very bottom, regardless of where they sit in sections — pulled out of the normal per-section .map() into a
separate pass, since a footer belongs at the end of the page, not wherever an admin happened to drag it in the
builder’s reorderable list (discuva-admin’s own SECTION_TYPE_META.FOOTER.description says this explicitly, so
it’s not a surprise). When a page has no FOOTER section, AutoFooter renders instead — a small, fixed,
non-editable footer (church name + © {year}, nothing else) so a page never just stops abruptly after its last
content section. Adding a real FOOTER section replaces that default entirely with whatever the admin
configures (custom text, links, social links, copyright toggle, contact-info toggle) — there’s no partial-merge
between the two. Nothing stops an admin from adding more than one FOOTER section (same as any other type); all
of them render, in their relative order, rather than silently keeping just one.
socialLinks render as real brand icons, not text labels — a real user-reported gap: the first version rendered
each socialLinks entry as its platform name in plain text (e.g. “Instagram”), which read as an unfinished-looking
placeholder rather than the icon row every other footer on the web uses. lucide-react (the icon set used
everywhere else in this app) deliberately ships no brand/logo icons at all — a trademark-scope decision on their
end, confirmed by checking the installed package’s own icon manifest, not a version mismatch. Rather than pull in
a whole extra icon-library dependency for five icons, discuva-member’s new components/pages/social-icons.tsx
inlines each one as plain SVG path data sourced from Simple Icons (free, CC0-licensed, the standard credible
source for brand marks — the same data every major icon library re-exports under the hood) — InstagramIcon,
FacebookIcon, YoutubeIcon, TiktokIcon, XIcon. 'website' (the one non-brand entry — nothing to show a logo
for) keeps a plain generic icon, lucide-react’s own Globe. Each renders inside a filled circular badge
(FOOTER_SOCIAL_PLATFORM_ICON lookup in section-renderer.tsx): style.accentColor (or the page’s own, via the
same effectiveAccentColor fallback every other section uses) becomes the badge’s background, with
ctaTextColorClass — the same helper MerchSection’s CTA button already uses — picking white or near-black icon
color for contrast; with no accent set, a theme-appropriate neutral fill (bg-white/10 under Bold, bg-[#121212]/5
under Minimal) is used instead. Each link keeps aria-label/title set to the platform’s full name (e.g. “X
(Twitter)”) for accessibility, since the visible content is now icon-only.
Optional page header (Page.showHeader/headerLogoUrl) — off by default, page-level chrome like theme/
accentColor (draft/live split via AddPageShowHeader, a single migration covering both new columns plus
header_logo_url/draft_header_logo_url since none had shipped yet). When on, discuva-member renders a fixed
bar above every page: the church’s own logo (PublicPageDto.church.logoUrl, now added to resolveChurchInfo —
falls back to the LOGO_URL env default the same way TenantInfoController.toProfile() already does) or, once
overridden per-page via headerLogoUrl (e.g. a conference with its own mark distinct from the church’s), that
instead — with the church’s own name as a text fallback when no logo exists at all. Beside it, one link per
section that already has a heading (or title, for HERO) set: no new content field on any section type,
purely a reuse of what’s already there. STATS has no heading field at all, so it never gets a link; FOOTER is
excluded outright (it’s page chrome, not something to jump to).
Active-section highlighting, via IntersectionObserver, not a scroll listener — PageHeader
(components/pages/page-header.tsx) watches every linked section’s own DOM node (already rendered with
id={section.id}) through a thin activation band starting just below the fixed bar
(rootMargin: "-${barHeight}px 0px -70% 0px"); whichever section is inside that band gets its nav link
highlighted (the page’s accentColor when set, a plain strong theme color otherwise). Clicking a link intercepts
the default anchor jump and does its own offset scrollTo, since a plain href="#id" jump has no way to know
about the fixed bar’s height sitting on top of the target — the plain anchor still works with JS disabled, just
without the offset compensation.
position: fixed, deliberately not sticky — this app’s globals.css sets html/body to
overflow-y: auto everywhere, for the authenticated app shell’s hidden-scrollbar look. Having overflow: auto on
both of them breaks position: sticky on any descendant, even though neither element ever actually scrolls
independently (body’s own height already matches its content — the real scrolling happens one level up at
html, which is what makes body’s overflow: auto still count as a scroll container and break sticky’s
containing block). Two attempted fixes for this specific route didn’t work and aren’t worth repeating: a plain
<style> override loses to Next.js’s own managed stylesheet precedence (its data-precedence attribute controls
cascade order independent of DOM position, not source order — confirmed directly, it silently had no effect), and
even an imperative inline-style override (element.style.overflowY = "visible", which should out-rank any
non-!important stylesheet rule) was still overridden — unexplained, and not worth chasing further given fixed
sidesteps the entire question. position: fixed is relative to the viewport, unaffected by any ancestor’s
overflow. The unpublished-draft preview banner renders as part of the same fixed block when a page has a
header (stacked above the nav bar in normal flow, inside the fixed container) rather than as its own separate
sticky element — letting two fixed-position concerns coexist without hand-computing either one’s own top offset.
A ResizeObserver-measured spacer element right after the fixed block reserves the exact real space so page
content is never hidden underneath, self-adjusting live if the banner wraps to two lines on a narrow screen.
Four follow-up fixes from live use of the first version, all in PageHeader/Page.headerLinks:
- Logo bigger and given more room —
h-8(32px) →h-11(44px), with the bar itself growing fromh-14(56px) toh-16(64px) to match; the logo/name block also gained amax-w-[45%]cap so a very long church name (the text fallback) can’t crowd out the nav entirely on a narrow desktop window. navLabel(PageSectionDto.navLabel,PageSection.navLabel) — an optional per-section override of what that section’s link in the header says, e.g."Home"instead of a longHEROtitle. Unvalidated beyond the string type, same posture ashidden;sectionNavLabel()inpage-header.tsxchecks it first and only falls back to the section’s own heading/title when it’s unset, so this is purely additive — no existing page’s nav labels change unless an admin explicitly sets one.- Custom header links (
Page.headerLinks/draftHeaderLinks,HeaderLinkDto) — a plain{ label, url }[]jsonb array (added to the same still-unshippedAddPageShowHeadermigration alongside the columns above, rather than a second migration, since it hadn’t been deployed yet), shown in the nav after the automatic per-section links, always opening in a new tab (target="_blank") regardless of what they point to — same behaviorFOOTER’s owncontent.linksalready has.HeaderLinkDtorequires non-emptylabel/urlon each entry (@IsNotEmpty()), the one place this feature validates content beyond a bare type check.duplicate()deep-clonesheaderLinksthe same way it already doessections, for the same “two genuinely independent pages” reasoning. The storage shape stays exactly this simple — notype/pageIddiscriminant, no server-side resolution — because the “pick one of this church’s other Pages, or type an external URL” experience an admin actually wants lives entirely in discuva-admin’s editor instead (below): picking a Page there just writes that page’s already-known public URL into the same plainurlfield, so this API/DTO/entity needs no awareness that the distinction exists at all. - discuva-admin’s link editor: a Page/Link toggle per link (
app/pages/page.tsx) — “Page” mode shows a<select>of every page this church has (usePages()'s already-loadedpageslist — no new fetch), each labeled by its own title with" (draft)"appended for anything unpublished (still selectable — two pages being built together may need to cross-link before either publishes), and writes that page’s resolved public URL (getTenantMemberAppUrl()+/p/{slug}) straight intourl; “Link” mode is the original plain text input. Purely local editing state (headerLinkModes, index-aligned withheaderLinks, never sent to the backend) — re-opening a saved page infers each link’s mode by checking whether itsurlalready matches one of this church’s own page URLs, defaulting to “Link” otherwise. The one accepted tradeoff of not storing a livepageIdreference: if the target page’s slug is later renamed, a “Page”-mode link saved before that rename goes stale (still points at the old slug) exactly like a “Link”-mode one would if someone changed that same URL elsewhere — deliberately not solved by this feature, in favor of keeping the API surface this simple. - The fixed bar now casts a
shadow-sm, and the spacer reservesbarHeight + 12rather than an exact match — a real reported complaint: on mobile, an edge-to-edgeHEROimage (which has no padding of its own to borrow) sitting flush against the header read as “stuck,” especially with mobile’s already-tight vertical space. The extra 12px isn’tHERO-specific — it’s a universal small gap under the header, benefiting every section a page might open with — and the click-to-scroll offset inhandleNavClickwas updated to match (barHeight + 12) so a clicked link’s target lands with the same breathing room the initial page load already has, not flush against the bar. - Horizontal padding grows with the viewport (
px-6 sm:px-10 lg:px-16, was a flatpx-6) — a real reported complaint on a genuinely wide desktop window: the logo and the last nav link both sat pinned to the literal edge of the browser, which read as unfinished rather than deliberate. A flat padding value that looked fine on a laptop screen just doesn’t scale to a 3000px-wide monitor. - Bug fix: a
navLabelon aFOOTERsection was silently giving it a nav link, contradictingFOOTER’s own “page chrome, not something to jump to” rule (SECTION_TYPE_META.FOOTER.description) — introduced bynavLabelchecking before the typeswitchinstead of after.sectionNavLabel()now excludesFOOTERoutright, before thenavLabelcheck, so setting one there (nothing in discuva-admin’s own editor stopped that) still can’t put it in the nav. A side effect worth naming, not a second bug:STATS— which has no heading field of its own — now can get a nav link if given an explicitnavLabel, since it isn’t specifically excluded the wayFOOTERis; previously it could never appear in the nav at all.
discuva-admin’s Header editor redesigned (app/pages/page.tsx) — four follow-up complaints from live use:
- Visual organization: the whole thing now lives inside one bordered card with a distinct toggle strip at the top, instead of a flat block sitting between Theme and Font with no visual separation of its own.
- A live preview (
HeaderPreview, module-level inpage.tsx) — a small static mock of the real header (logo/ name, nav pills, first one in the accent color) computed from whatever’s currently in the editor:sectionsmapped throughpreviewNavLabel(a duplicate of discuva-member’s ownsectionNavLabel— same “two independent repos” duplicationPageSectionitself already has) plusheaderLinks, so an admin sees roughly what it’ll look like without saving and opening the real preview link. Not pixel-identical to the realPageHeader— no fixed positioning or scrollspy needed for a thumbnail — and the logo falls back throughheaderLogoUrl ?? tenant?.logoUrl(useTenant(), the same tenant-branding context the rest of discuva-admin already reads from) so the preview looks right even before this page has its own logo override set. - A cleaner links editor: collapsed from two stacked rows per link down to one — label, a small icon toggle
(
FileTextfor “Page” mode /ExternalLinkfor “Link” mode, single click cycles between them) that replaced the previous two-button text-pill toggle, the dropdown-or-URL field, and remove, all in one row. - The per-section “Nav label” field is now conditional: only rendered when the page’s header is actually on
(
SectionsEditor/SectionContentEditorboth gained ashowHeaderprop, threaded down frompage.tsx), and never rendered at all forFOOTERsections — matching the same “Footer is never navigable” rule the bug fix above enforces on the rendering side, so the editor doesn’t offer a field that would silently do nothing.
REGISTRATION’s embedded form card (discuva-member, components/forms/embedded-form-fill.tsx) — several
usability fixes, all in this one shared component (not Pages-specific, but only ever mounted from
RegistrationSection):
- The form’s own
titlenow renders as a heading abovedescription. It never did before — onlycoverImageUrlanddescriptionrendered, so a form whose admin had typed a placeholder/test string intodescription(with nothing above it for visual context) read as a stray, unstyled label rather than a proper form name.titleis unconditional (aFormalways has one); its own bottom margin absorbsdescription’smb-5whendescriptionis unset, so the gap before the first field stays consistent either way. align, when set on theREGISTRATIONsection’sstyleandlayoutis'stacked'(the default), keeps each theme’s original default when unset —'bold'still centers the heading/body and card by default,'minimal'still left-aligns, exactly as before this knob existed.textAlignClass’s own “unset → center” fallback is deliberately not reused here for that reason: applying it directly would have silently re-centered every already-published Minimal-themeREGISTRATIONsection the moment this shipped.- Long forms are handled two ways, not just a hint — a real regression risk flagged by a user testing an
actual (short) test form and asking “what happens with a lot of fields”: a hint alone doesn’t stop a long
unpaginated form from visually dominating the page if an admin never acts on it.
- Discoverability, unchanged from the first pass:
PaginatedFormFillFieldsalready becomes a real Back/Next wizard with a progress bar the moment anyFormFieldon the underlyingFormhas apageIndexset — that mechanism already existed and needed no changes. discuva-admin’sRegistrationEditorshows an inline hint (“consider splitting it into steps”) whenever the selected form has ≥6 fields and they’re all still on one page (LONG_FORM_FIELD_THRESHOLD,app/pages/sections-editor.tsx) — a hint, never a block, since a genuinely short form is fine flat. - Auto-chunking as the safety net for when an admin doesn’t act on the hint:
EmbeddedFormFill’swithAutoChunkedPagesgroups a form’s fields into synthetic pages of 5 (AUTO_CHUNK_SIZE) purely for rendering, whenever a form has ≥6 fields (AUTO_CHUNK_FIELD_THRESHOLD) and the admin never set any explicitpageIndex— no data is written back, and the standalone/forms/public/:idfill page (PublicFormFillClient) is entirely unaffected, since it callsPaginatedFormFillFieldsdirectly with the real fields rather than through this function. An admin who does set explicitpageIndexvalues (real, deliberate groupings via the hint) is left alone completely — auto-chunking only ever fills a gap, never overrides a real choice. - A height cap as a backstop for the remaining edge case — an admin who explicitly paginates but still
dumps too many fields onto one page bypasses auto-chunking (any field with
pageIndex > 0counts as “already split”).PaginatedFormFillFieldsgained an opt-incapFieldsHeightprop that wraps just the current page’s fields (not the progress bar or Back/Next/Submit row, which stay outside the scroll region and always visible) in amax-h-[480px] overflow-y-autocontainer. Opt-in and Pages-only: the standalone fill page never passes it, so its behavior is unchanged; onlyEmbeddedFormFillpassescapFieldsHeight(always — harmless for a short page, since it never reaches 480px). - Pagination itself stays a Forms-builder concept either way, not something reimplemented inside Pages — auto-chunking is a rendering-only fallback, not a second pagination mechanism.
- Discoverability, unchanged from the first pass:
- Field input text is explicitly dark (
components/forms/form-fill-fields.tsx’sinputClass) — a real regression caught by a user typing into a live Bold-themed page: the sharedinputClass(used by every text/number/email/phone/date input,<textarea>, and<select>) never set its own text color, so it silently inherited the ambient page text color. On the standalone/forms/public/:idfill page that’s always been browser-default black against a light page, so it never looked broken — but the embedded card is always white/light regardless of the Page’s theme (seeRegistrationSection’s own comment), and a Bold-themed page setstext-[var(--bold-fg)](white, for a dark background) on its outer wrapper. That white color cascaded all the way into the input’s typed text, invisible against the input’s own light#F9F9F9background — labels and placeholders already had their own explicit gray, which is why only the typed value went missing. Fixed by adding an explicittext-[#121212]toinputClassitself, benefiting both the embed and (harmlessly, since it already rendered dark by default) the standalone fill page. - The multi-page progress bar now carries a “Step X of Y” label (
PaginatedFormFillFields’sProgressBar) — the bar was a bare, unlabeledh-0.5line; a real visitor mistook it for how much of the form they’d filled in (field-completion progress) rather than which page of the wizard they were on. Benefits every multi-page form everywhere the shared component renders, not just Pages. - That label’s count reacts to conditional visibility, not just structural
pageIndex— a second real report: a page whose only field is conditionally shown (e.g. a “which team?” follow-up that only appears once “Want to volunteer?” is answered “Yes”) left the label reading “Step 1 of 2” even when the current answers meant page 2 had nothing visible on it — while the Submit button (isLastPage, computed via the sameisFieldVisiblecheck) already correctly knew there was no reachable second page and showed itself instead of “Next”. The two disagreeing was the actual bug.visiblePageIndices(new) recomputes, on every render, which structural pages currently have ≥1 visible field given the livevalues;isSinglePageand the bar’scurrent/totalare both driven by this instead of the raw structuralpageCount, so the label now always agrees with what the button is about to do. Deliberately not the same kind of value aspageitself (the currently-mounted page index) — that one must stay fixed while the visitor is looking at it, precisely so an earlier answer changing mid-view can’t yank them off their current page (see this component’s own file comment); recomputing what the label displays carries none of that risk, since it never triggers navigation on its own. - A form’s own name/description can be hidden (
RegistrationContent.hideFormMeta, boolean, off by default) — a direct consequence of thetitle-rendering fix above: once a form’s own name became visible, an admin whoseREGISTRATIONsection already has its own heading/body (or whose form’s title/description was never meant to be visitor-facing, e.g. an internal name) needed a way to suppress it again.EmbeddedFormFilltakes ahideMetaprop;RegistrationSectionpassescontent.hideFormMetastraight through. Not validated server-side — a plain boolean UI flag, same “harmless if malformed” precedentHERO.hideOverlayTextalready sets. - A true two-column split layout (
style.layout: 'split', distinct fromalign) puts the heading/body in its own column beside the white form card, side by side — mirrorsABOUT.content.layout’s own stacked/split concept, but lives instylerather thancontentsince it’s a page-builder layout choice, not something the section’s meaning depends on. Falls back to stacked when there’s no heading/body to put in the text column, same “nothing to split against” guardABOUT’s own split uses for its image. Whenlayoutis'split',alignis repurposed:'left'puts the form card on the left (text right), anything else (including unset) puts it on the right (text left) — the same “unset defaults to the same sideABOUT’s ownimagePositiondefaults to” convention. discuva-admin’sSectionStyleControlsreflects this by swapping the Alignment control’s label and options to “Form Position” (Left/Right only, no Center) wheneverstyle.layout === 'split'is selected for that section. The two columns useitems-center, notitems-start— the form card’s height varies with field count/pagination and the text column’s with copy length, so the two are rarely equal; a real page with short copy next to a multi-field form showed a visibly empty gap under the text withitems-start, whichitems-centerredistributes evenly above and below instead. The text column also needsspace-y-3on itsFormattedTextwrapper (a real gap fixed alongside this) — without it, multiple blank-line-separated paragraphs inbodyrender with no visible space between them, since Tailwind’s preflight zeroes<p>margins by default.
sectionHeadingClass now scales up on desktop, not a flat text-2xl (discuva-member, shared by every
content.heading on Speakers/Schedule/Testimonials/FAQ/Merch/Countdown) — a real, measured report: it was the
one heading treatment in this whole file with no responsive scale-up at all (24px at every viewport), while every
other heading (HeroSection’s title, AboutSection’s splitHeadingClass, RegistrationSection’s own
splitHeadingClass) scales up via a md: breakpoint. Confirmed via getComputedStyle against a real page — FAQ
measured 24px next to a sibling split heading measuring 30px for what’s visually the same role. Now
text-2xl md:text-3xl, matching RegistrationSection’s split heading exactly; fixing the shared function fixes
all six section types at once, not just the one that got reported.
FAQ answer text is no longer two legibility cuts stacked on the question at once (discuva-member,
FaqAccordion) — the question is text-sm font-medium; the answer was text-xs font-light at 70% foreground
opacity — three separate reductions (size, weight, and opacity) compounding into text a user directly compared
unfavorably against a reference page, where question/answer stay much closer in size, differentiated mainly by
color. Now text-sm (same size as the question) with the default weight (no font-light) and --bold-fg-80/
text-gray-600 (up from -70/text-gray-500) — confirmed via getComputedStyle against a real page: answer
went from 12px/300-weight/70%-opacity to 14px/400-weight/80%-opacity, now matching the question’s 14px
exactly.
FAQ gained a size knob (the one addition, not a general heading/subheading/body scale system — considered
and deliberately declined; see below) — style.size, added to SECTION_STYLE_APPLICABILITY.FAQ alongside its
existing accentColor, scales the section heading and the question/answer text together, as one bounded
4-option choice, never as independent controls per element. sectionHeadingClass (discuva-member,
section-renderer.tsx) gained an optional second parameter — a size-class override defaulting to its existing
text-2xl md:text-3xl, so the other five callers (Speakers/Schedule/Testimonials/Merch/Countdown) are completely
unaffected by its existence. FAQ_HEADING_SIZE_CLASS/FAQ_TEXT_SIZE_CLASS (section-style.ts) hold the four
buckets; md is deliberately identical to the pre-existing defaults (text-2xl md:text-3xl heading, text-sm
question/answer), so an unset size renders byte-for-byte the same as before this knob existed. FaqAccordion
gained a textSizeClass prop (default "text-sm") applied to both the question <span> and the answer <p>,
keeping them the same size at every bucket — matching the same “question and answer stay close in size” reasoning
the legibility fix just above already established. Why not a general text-size system: raised directly by a
user after several rounds of “this text is too small” reports — every prior report turned out to be a real bug or
inconsistency (missing responsive scale-up, three compounding legibility cuts), not a case where a well-tuned
default was simply wrong for one church’s taste. Exposing independent heading/subheading/body size controls
across every section would trade “consistently coherent defaults, tuned together” for “combinatorially many ways
for a page to look mismatched” — a larger source of future complaints, not a smaller one. The existing size
knob (Stats/Speakers/Merch/Countdown, now FAQ) stays the deliberately narrow shape: one bounded, 4-option “how
prominent is this section’s main content” choice per section, extended only where a real reported need shows up,
never a raw pixel-scale system.
size extended to every section’s body/paragraph text — a direct follow-up request (“add this to every body
text in the pages setup”), applied to HERO’s subtitle, ABOUT’s body (both stacked and split), REGISTRATION’s body
(both stacked and split), and TESTIMONIALS’ quote, via SECTION_STYLE_APPLICABILITY.HERO/ABOUT/REGISTRATION/
TESTIMONIALS in discuva-admin each gaining a size entry (ABOUT’s is set unconditionally on the static table
entry rather than in getStyleApplicability’s dynamic split/stacked branch, since — unlike align — body text
exists in both layouts). This is not the declined general system: it’s the same one bounded size knob each
section already had a slot for, just exposed for more sections’ plain paragraph copy, not an independent
heading/subheading/body control added on top. FAQ_TEXT_SIZE_CLASS (discuva-member, section-style.ts) was
renamed to BODY_TEXT_SIZE_CLASS and its comment widened accordingly — sm/md/lg/xl map to text-xs/
text-sm/text-base/text-lg, shared by every one of these callers rather than a per-section table, since plain
body copy doesn’t need section-specific buckets the way Stats/Speakers/Merch/Countdown’s one prominent element
knobs do. Every call site follows the “only override when explicitly set” pattern (style?.size ? BODY_TEXT_SIZE_CLASS[style.size] : <original hardcoded default>) rather than forcing an unset size through
md, because About and Registration each already had two different pre-existing defaults (stacked vs. split)
that a single md bucket can’t simultaneously reproduce — leaving size unset must still render byte-for-byte
identical to every already-published page regardless of which layout it’s in.
Size control now explains what it resizes — with the knob live on 9 of 10 section types, “Small/Medium/Large/
X-Large” alone (no caption, unlike Spacing’s always-present “Room above and below this section…” line) left an
admin unable to tell what clicking it would actually change without trial and error. StyleApplicability.size
changed from a plain boolean to a string — the presence check (applicability.size &&) and the caption text
are now the same value, so there’s no separate lookup table that could drift out of sync with the applicability
table itself. Each section type gets its own one-line caption (e.g. Stats: “Size of the stat numbers.”, Speakers:
“Size of each speaker’s photo.”, Registration: “Size of the heading and body text beside the form.”, FAQ: “Size of
the heading and the question/answer text.”), rendered by SectionStyleControls directly under the Size buttons,
matching the Spacing control’s existing caption pattern exactly.
Validation is envelope-only at the DTO layer (PageSectionDto: id/type/content as a plain object) —
per-type structural validation happens in PageService.assertValidSections, a switch (section.type) checking
each type’s required fields, the same “jsonb content a decorator alone can’t cross-check” pattern
FormService.assertValidOptionMetadata/assertValidPostSubmitOutcomes already use, rather than a
class-transformer discriminated union (deliberately not introduced, to keep this module’s validation style
consistent with the rest of the codebase). REGISTRATION’s formId is the one genuinely cross-referential check
— it must reference a Form that actually exists in this tenant (formRepo.findOneBy).
Endpoints — two controllers, not three like Forms (a Page has no authenticated-member behavior; it’s purely public or admin-managed):
PageAdminController(AdminGuard+PAGES_READ/PAGES_WRITE): full CRUD (GET/POST /pages,GET/PATCH/DELETE /pages/:id),POST /pages/:id/images— one generic multipart upload endpoint reused by every image slot in every section type (hero background, each speaker photo, gallery), returning{url, publicId}only, never touching thePagerow itself (the client embeds the url into whichever section’s content it belongs to on the next save) — andPOST/DELETE /pages/:id/og-image(mirrorsFormService.setCoverImage/removeCoverImage’s “delete the previous Cloudinary asset only after the new one is safely saved” ordering). Admin-only image uploads mean the volume of an abandoned upload (started, page edit never saved) is low enough that — unlikeFormFieldAttachment’s visitor-facing uploads — no orphan-cleanup sweep is built for this; an accepted v1 tradeoff, not an oversight.PagePublicController(@Public(), no guard):GET /pages/public/:slug— published-only, returns the fullPageincluding every section verbatim. Unlike Forms’PublicFormDto, nothing is stripped — every section is content the church chose to show publicly, there’s no “spoiler” concern the way an unselectedDROPDOWNoption’soptionMetadatahas. Not rate-limited (read-only, unlike Forms’ public write endpoints). AlsoGET /pages/public/:slug/preview?token=...(draft content, gated bypreviewTokeninstead ofisPublished— see the draft/publish section above) andGET /pages/public(every published page for the resolved tenant,{slug, title, seoDescription, updatedAt}only — feeds discuva-member’ssitemap.xml/robots.txt/llms.txt, described just below).listPublishedfilters onisPublishedalone (not narrowed byslugthe waygetForPublicis), backed byAddPagesPublishedIndex1796626800000’s partial index (WHERE is_published = true) rather than a full-column one — thefalseside (most pages, most of the time) never needs to appear in it.
pages:read/pages:write are backfilled onto every existing tenant’s SuperAdmin role by
GrantPagesPermissions1796194800000 (same class of fix as GrantFormsPermissions/GrantSocialMediaPermissions
— a brand-new AdminPermission is only auto-granted to a SuperAdmin role at the moment that role row is
created, a one-time Object.values(AdminPermission) snapshot, so every tenant provisioned before this module
existed needs the new permission strings appended explicitly). CreatePagesTable1795849200000 shipped without
this grant migration, which is why the “Pages” sidebar entry (gated behind pages:read in discuva-admin’s
NAV_STRUCTURE) silently never appeared for any pre-existing tenant even though the module itself worked once
reached directly.
Early-access rollout, controlled by the Pages Rollout control (§Platform Admin — Tenant Management) — pages
is a toggleable module (KNOWN_MODULES) not included in any plan’s features by default (no @RequiresPlan/
PlanGuard on either controller, unlike Forms), same posture Social Media used before it went GA
(MakeSocialMediaOverrideOnly1793736000000’s own comment). ModuleEnabledGuard’s own plan-feature resolution
(PlanFeatureResolverService) already checks Tenant.moduleOverrides[moduleKey] ahead of plan membership either
direction — true grants access regardless of plan, false blocks it regardless of plan — so a platform admin
grants specific churches under test access from discuva-platform’s dedicated Pages page (GET/PUT /platform/pages/rollout, the same one-toggle-plus-multi-select mechanism Social Media Rollout uses, see below),
with every other tenant getting a 403 from isEnabled’s plan-membership fallback until the rollout is flipped to
“everyone.” PageAdminController.isPlatformEnabled
(GET /pages/platform-enabled) is a lightweight, side-effect-free access ping the discuva-admin frontend reads to
decide whether to render the real builder or a “Coming Soon” panel — reaching the handler at all already proves
access, since ModuleEnabledGuard 403s first otherwise. PagePublicController carries the same @RequiresModule
ModuleEnabledGuard(no separate plan check needed there either) — a tenant without access could never have created a page to view publicly anyway.PageAdminController’sGET /pages/:idis a wildcard route, soPagePublicControlleris registered first inPagesModule.controllers— otherwise it would swallowGET /pages/public/:slug, the same route-ordering issueFormsModulealready documents.
Visitor-submitted testimonials (CreateTestimonialSubmissionsTable1796799600000) — a TestimonialSubmission
entity (page FK ON DELETE CASCADE, sectionId — the section’s client-generated jsonb id, not a real FK since
sections aren’t DB rows — quote, nullable name, status defaulting to 'PENDING', indexed on
(page, status)) lets a visitor submit their own testimony on a TESTIMONIALS section that has
content.acceptSubmissions: true. PageService.submitTestimonial (public, unauthenticated) rejects outright
unless the referenced sectionId actually exists on that exact page and is a TESTIMONIALS section with
acceptSubmissions on — a stale or guessed sectionId can’t attach a submission to a section that never opted
in. Every submission lands PENDING; an admin approves or rejects it via listTestimonialSubmissions/
moderateTestimonialSubmission. Only APPROVED rows ever reach a visitor — getForPublic/getForPreview merge
them (mapped to { quote, name }, no photo — public submission never accepts an image upload) onto the relevant
section’s content.items server-side, on every request, so discuva-member’s rendering needs no second fetch:
it just sees a possibly-longer items array. The submit endpoint follows the app’s one established public-write
convention exactly — @Public() + @Throttle({ default: { limit: 5, ttl: 60_000 } }), the same pattern
FormPublicController’s submit route already uses; there is no CAPTCHA/honeypot convention anywhere in this
codebase, so none was introduced here either.
No custom-domain resolution, no auto-provisioned homepage. A page is reachable at
member.<church-subdomain>.<baseDomain>/p/<slug> today, resolved the same way discuva-member’s existing public
form-fill pages resolve tenant (subdomain read from the Host header server-side, or the X-Tenant-Subdomain
header client-side — see that app’s own tenant-resolution notes). There’s no isHomepage designation and no
“every tenant gets a default page” provisioning — a church’s first page can be a homepage or a conference page,
same feature either way; both are natural fast-follows once this is in active use, not built for v1.
SEO/LLM discoverability (discuva-member) — app/p/[slug]/page.tsx sets alternates.canonical and, for a
non-preview request, injects two JSON-LD <script type="application/ld+json"> blocks: a WebPage schema always,
and an FAQPage schema (mapping each FAQ section’s {question, answer} pairs to mainEntity) whenever the
page has one — a direct match for Google’s FAQ rich results and the kind of thing LLM answer engines cite
directly. No Event schema: a HERO section’s dateRangeText is free text (“March 5–7, 2026”), not a real
date, so there’s no reliable startDate to populate — that would need the section’s content model to gain a
real structured date field first. Three new tenant-aware routes, all reading the request’s Host header the
same way app/manifest.ts already does (none of them can be statically generated at build time for that
reason): app/sitemap.ts (/sitemap.xml, one entry per published page via GET /pages/public), app/robots.ts
(/robots.txt, allow: /p/, disallow: / — everything else is an authenticated member-only screen with
nothing for a crawler), and app/llms.txt/route.ts (/llms.txt, an emerging, not-yet-formally-standardized
convention some LLM crawlers/agents read the way traditional crawlers read robots.txt — a markdown summary of
the tenant’s name and its published pages).
A preview link is explicitly noindex, nofollow, never just “not linked from anywhere.” robots.ts’s
allow: /p/ rule can’t tell a preview URL (?previewToken=...) apart from the live one at the same path — it
only ever sees the URL’s path, not its query string — so that file alone doesn’t keep a leaked preview link out of
a search index. generateMetadata closes that gap directly: an unpublished/draft request (resolved.isPreview)
now returns { robots: { index: false, follow: false } } instead of falling through to an empty {} (which would
silently inherit the default indexable behavior — Next only emits a noindex meta tag when a route’s own
generateMetadata says so). The rest of preview mode’s existing “no OG/canonical/JSON-LD” posture is unchanged;
this is strictly an addition, not a behavior change to the live path. A nonexistent slug doesn’t need the same
treatment — notFound() already produces a real 404, which no crawler indexes regardless of any meta tag.
| Method | Route | Auth | Notes |
|---|---|---|---|
| GET | /pages/platform-enabled |
AdminGuard (PAGES_READ) | { enabled: true } always — the “Coming Soon” gate; reaching this handler at all already proves access (see above) |
| POST | /pages |
AdminGuard (PAGES_WRITE) | Create a page with its sections in one call |
| GET | /pages |
AdminGuard (PAGES_READ) | List all pages — unpaginated, same policy as Forms. Each row also carries churchCalendarEntitled: boolean (tenant-wide, same value on every row) — the builder opens a page for editing straight from this list, not a separate per-id fetch, so the “requires upgrade” badge on a CHURCH_CALENDAR section needs it here |
| GET | /pages/:id |
AdminGuard (PAGES_READ) | Get one page with sections, plus the same churchCalendarEntitled: boolean GET /pages carries |
| PATCH | /pages/:id |
AdminGuard (PAGES_WRITE) | Update page. title/seoDescription/theme/accentColor/backgroundColor/fontFamily/showHeader/headerLogoUrl/headerLinks/sections write to draft* only (arrays = replace wholesale, no per-item id to diff against; each section may carry an optional style: {align?, columns?, size?, accentColor?}, an optional hidden: boolean, and an optional navLabel: string); slug/isPublished still write live immediately |
| DELETE | /pages/:id |
AdminGuard (PAGES_WRITE) | Delete a page |
| POST | /pages/:id/publish |
AdminGuard (PAGES_WRITE) | Copies every draft* field onto its live counterpart and sets isPublished = true |
| POST | /pages/:id/duplicate |
AdminGuard (PAGES_WRITE) | Body { slug, title? }. Copies the source page’s current draft into a brand-new, unpublished page under the given slug — see PageService.duplicate’s own comment |
| POST | /pages/:id/images |
AdminGuard (PAGES_WRITE) | Multipart, field name file, max size MAX_PAGE_IMAGE_UPLOAD_MB. Generic upload for any section’s image slot — returns { url, publicId } only, doesn’t touch the page row |
| POST | /pages/:id/og-image |
AdminGuard (PAGES_WRITE) | Multipart, field name file. Sets Page.draftOgImageUrl |
| DELETE | /pages/:id/og-image |
AdminGuard (PAGES_WRITE) | Clears the draft OG image |
| GET | /pages/:id/testimonial-submissions |
AdminGuard (PAGES_READ) | Optional ?status=PENDING|APPROVED|REJECTED. Moderation queue for a TESTIMONIALS section with acceptSubmissions on |
| PATCH | /pages/:id/testimonial-submissions/:submissionId |
AdminGuard (PAGES_WRITE) | Body { status: 'APPROVED' | 'REJECTED' } |
| GET | /pages/public |
Public | Every published page for the resolved tenant — {slug, title, seoDescription, updatedAt} only |
| GET | /pages/public/:slug |
Public, 404 unless isPublished |
Returns the full PublicPageDto (theme/accentColor/backgroundColor/fontFamily) — every section verbatim including its optional style, except any section with hidden: true (dropped from the array entirely) |
| GET | /pages/public/:slug/preview |
Public, ?token= must match previewToken |
Same PublicPageDto shape (hidden sections filtered the same way), sourced from draft* — no isPublished check |
| POST | /pages/public/:slug/testimonials |
Public, rate-limited (5/min) | Body { sectionId, quote, name? }. 202, no content. Lands PENDING — rejected outright unless sectionId is a TESTIMONIALS section on this page with acceptSubmissions on |
discuva-admin UX: same client-side search/filter and disclosure pattern as Forms, for the same reasons
(GET /pages is likewise a plain unpaginated find(), likewise admin-authored reference data). app/pages/page.tsx’s
list gained a search box (title/slug) plus a Published/Draft status filter. app/pages/sections-editor.tsx
already collapsed each section by default (collapsedIds, pre-existing) — but SectionStyleControls
(Layout/Alignment/Columns/Size/Accent Color/Spacing, up to six sub-controls depending on the section type) was
always rendered in full the moment a section was expanded, reproducing the same long-scroll problem one level
deeper. Now collapsed behind its own “Style” toggle, same dot-indicator-when-already-configured treatment as
Forms’ field editor.
Church Calendar (src/church-calendar/)
Admin-configurable, dated programme calendars — the in-app equivalent of the flyer a church already designs each month for social media (“Programs in the month of September, themed REMEMBERED — Special Thanksgiving on the 6th, Holy Communion on the 9th, …”). A ChurchCalendar has a title, an optional theme, a startDate/endDate range (a single month, a full year, or anything in between — not a fixed month field), an optional accentColor (hex, drives the admin-side exported flyer’s gradient bands — there’s no tenant-wide brand-color setting to fall back to, so the flyer template falls back to a built-in default when unset), an isPublished flag, and an ordered entries: ChurchCalendarEntry[] ({ id, date, time?, title, description?, imageUrl? }, plain jsonb, whole-array replace on save — same convention Page.sections/Form.postSubmitOutcomes already use, since there’s no per-entry DB row to diff against). id is client-generated. time is an optional 24-hour HH:mm string (@Matches on the DTO) — an all-day or time-TBD entry simply omits it; adding it required no migration since entries is jsonb. The admin builder flags (non-blocking — never rejected server-side) two entries sharing the same date and time as a likely scheduling conflict, since two things legitimately happening at once (e.g. Kids Church and the Main Service) isn’t necessarily a mistake.
Validation (ChurchCalendarService, mirrors PageService.assertValidSections’s “structural checks the decorator layer can’t express” pattern): endDate cannot be before startDate; every entry’s own date must fall inside [startDate, endDate], and every entry needs a non-empty title. Entries are sorted by date before persisting (on both create and any update that replaces entries) so the admin list and the member view always render in date order regardless of the order entries arrived in the request.
Two controllers, deliberately on different base paths to avoid a route-ordering hazard — PagesModule’s own public/admin controllers share one base path and depend on registration order in the module’s controllers array to keep the wildcard :id route from swallowing the more specific one; ChurchCalendarMemberController sidesteps that entirely by mounting at church-calendar/member instead of sharing church-calendar with the admin controller’s :id wildcard.
ChurchCalendarAdminController(AdminGuard+CHURCH_CALENDAR_READ/WRITE,@RequiresModule('church_calendar'), and@RequiresPlan(PlanFeature.CHURCH_CALENDAR)+PlanGuard— unlike Pages, Church Calendar is a normal Pro-plan feature, not override-only early access): full CRUD plusPOST /church-calendar/:id/images, a generic multipart upload reused by every entry’s photo slot (mirrorsPageAdminController’s image endpoint exactly —{ url, publicId }only, doesn’t touch the calendar row; the caller embeds the url into whichever entry it belongs to on the next save). Same “no orphan-cleanup sweep for an abandoned upload” accepted tradeoff as Pages’ section images, for the same reason (admin-only, low volume).ChurchCalendarMemberController(JwtAuthGuard, member+worker, same@RequiresModule/@RequiresPlan/PlanGuardgating):GET /church-calendar/member/current— published calendars whoseendDatehasn’t passed yet, ordered bystartDateascending (so a shorter “this month” calendar and a longer-running “this year” one can both surface together). “Today” is computed via a newDateService.today()method (see below) rather than a barenew Date(), so a calendar doesn’t disappear a few hours early/late for a church whose timezone differs from the server’s.
Plan/permission plumbing — the same three pieces Pages needed, done proactively this time instead of as a follow-up fix:
AdminPermission.CHURCH_CALENDAR_READ/CHURCH_CALENDAR_WRITE, a newChurch Calendarpermission group.KNOWN_MODULESkeychurch_calendar.PlanFeature.CHURCH_CALENDAR, added to all four Pro plan variants’featuresbyAddChurchCalendarToProPlans1793908800000—AddFormsToProPlan(the precedent) only targeted the bareprorow, leavingpro-annual/pro-usd/pro-usd-annualwithoutforms; this one covers all four so the feature isn’t inconsistently available depending on which Pro variant a tenant happens to be subscribed to.GrantChurchCalendarPermissions1796367600000backfillschurch_calendar:read/writeonto every existing tenant’sSuperAdminrole. This is the exact fixGrantPagesPermissionshad to ship as a follow-up after the Pages sidebar entry silently never appeared for any pre-existing tenant — done as part of the same PR here instead of after the fact.
Events flow into calendars (include_events, default on). A calendar is a view of the scheduled events in its date range plus manual “other dates” (themes, fasting periods, holidays), so services aren’t entered twice. ChurchCalendarService.withItems loads events by startTime (a day either side, then filtered on the church-local date via ChurchTimezoneService), and util/calendar-items.ts merges them with the manual entries: a manual entry with the same date and title as an event is folded into it (its photo/description kept); repeat_display SUMMARY (default) collapses a repeating service (same recurringEventId, 2+ dates in range) into one line with a label read from the dates (“Every Sunday”, “Every other Wednesday”, “Daily”, else “N dates”), EACH lists every date; hidden_event_keys (event:<id> / series:<recurringEventId>) leaves chosen events off. Responses carry the merged list as items — admin GET/POST/PATCH /church-calendar[/:id] (all audiences, plus eventOptions for the editor’s show/hide list), GET /church-calendar/member/current (only events meant for the caller, via eventVisibleToViewerSql) and Pages’ CHURCH_CALENDAR sections (EVERYONE events only). Migration AddCalendarEventOptions. accent_color is now the calendar’s colour theme (presets + custom) used by the flyer and the member app, with text colour chosen for contrast. Admin “Make it an event” on a manual date opens /events?new=1&name=&date=&time= pre-filled. A calendar may now be saved with no manual entries when it includes events.
New CloudinaryFolder member 'church-calendar-images' and new PlatformSettingKey.MAX_CHURCH_CALENDAR_IMAGE_UPLOAD_MB (default 5MB, same shape as MAX_PAGE_IMAGE_UPLOAD_MB).
The member app’s list-page header is its own KNOWN_ASSETS entry (church-calendar-hero, src/tenant/constants/known-assets.constant.ts), overridable from discuva-admin’s Mobile App Appearance page like every other page header. It was initially built reusing the existing events-hero key to save a step — fixed once flagged, since that would have meant a church customizing the Events page’s header silently changed Church Calendar’s too (or vice versa). Each page header gets its own key unless it’s one of the few deliberately shared ones (e.g. prayer-hands-bible across prayer/prayer-requests/evangelism) — reusing one should be a conscious choice, not a shortcut.
DateService.today() (new): today’s date as a plain 'yyyy-MM-dd' string in the church’s configured timezone, for comparing against a date-typed column. Added because nothing on DateService already did this — format(new Date(), 'yyyy-MM-dd') renders using the server process’s timezone, which can land on the wrong calendar day near midnight for a church whose timezone differs from the server’s; today() runs new Date() through the same toZonedTime shift startOfDay()/endOfDay() already use before formatting, so the date components come out right regardless of what timezone the Node process itself is running in.
| Method | Route | Auth | Notes |
|---|---|---|---|
| POST | /church-calendar |
AdminGuard (CHURCH_CALENDAR_WRITE) | Create a calendar with its entries in one call |
| GET | /church-calendar |
AdminGuard (CHURCH_CALENDAR_READ) | List all calendars — unpaginated, same policy as Pages/Forms |
| GET | /church-calendar/:id |
AdminGuard (CHURCH_CALENDAR_READ) | Get one calendar with entries, merged items and eventOptions (events in range, with hidden) |
| PATCH | /church-calendar/:id |
AdminGuard (CHURCH_CALENDAR_WRITE) | Update calendar. entries omitted = untouched, an array = replace wholesale; also includeEvents, repeatDisplay (SUMMARY/EACH), hiddenEventKeys |
| DELETE | /church-calendar/:id |
AdminGuard (CHURCH_CALENDAR_WRITE) | Delete a calendar |
| POST | /church-calendar/:id/images |
AdminGuard (CHURCH_CALENDAR_WRITE) | Multipart, field name file, max size MAX_CHURCH_CALENDAR_IMAGE_UPLOAD_MB. Generic upload for any entry’s photo slot — returns { url, publicId } only |
| GET | /church-calendar/member/current |
JwtAuthGuard (member+worker) | Published calendars with endDate >= today (church-timezone-aware), ordered startDate ascending; each with merged items (only events meant for the caller) |
Department Goals (src/department-goal/)
A per-department, per-review-cycle goal-setting and blind two-sided rating workflow: each cycle, every
department’s Head of Department (HOD) writes goals for their team during a short opening window; once that
window closes, goals lock and the department works toward them; at cycle end, both the church (via
discuva-admin) and the HOD (via discuva-member) rate each goal 1–5 with a reason, independently, and neither
sees the other’s score until both are in — then both reveal together to the HOD, Deputy-HOD, and department.
A DepartmentGoalCycle is global/church-wide — one shared opening window covers every department at once, not
a cycle per department.
Entities:
DepartmentGoalCycle(department_goal_cycles) —name,startDate/graceDeadline/endDate(plaindatecolumns,'yyyy-MM-dd', compared viaDateService.today()— same convention asPledgeCampaign/ChurchCalendar),isActive(church can deactivate/cancel a cycle early),approvalChain(nullablejsonb, an ordered array of up to 3{level, adminId}entries — same jsonb-array-of-plain-object patternForm.postSubmitOutcomesuses;null/empty is the default and means this cycle has no approval gate at all).DepartmentGoal(department_goals) —cycle(M:1,CASCADE— a goal doesn’t outlive its cycle),department(M:1,RESTRICT, notCASCADE— this is a compliance record; department deletion is blocked by existing goal history rather than silently erasing it, the same postureDepartmentService.deletealready takes when workers are still assigned),title(displayed as “KPI” — Key Performance Indicator — in both admin/member UIs; kept namedtitleinternally, no migration needed for a display-only relabel),description(nullable, displayed as “KPI Description”; previously informally doubled as “the measurable target” in discuva-member’s placeholder copy — that role now belongs totimelineToAchieve),timelineToAchieve(nullable text, displayed as “Timeline to Achieve Target” — freeform, e.g. “Q3 2026” or “by end of cycle”, never validated as a date or enforced, deliberately distinct from the cycle-levelstartDate/graceDeadline/endDatewhich do carry real enforcement),churchRating/selfRating(nullable smallint, 1–5),churchRatingReason/selfRatingReason(nullable text),churchRatedAt/selfRatedAt(nullable timestamptz),churchRatedByAdmin/selfRatedByMember(nullable FK,SET NULL). A goal’stitle/description/timelineToAchievebecome immutable (service-layer check, not a DB constraint) the instant either rating is set.DepartmentGoalApproval(department_goal_approvals) — one row per(cycle, department),@Unique(['cycle', 'department']), created lazily the moment that department’s HOD writes their first goal in a chain-configured cycle (never eagerly backfilled across every department).currentLevel(smallint, default 1 — which of the cycle’sapprovalChainlevels is currently active),status(PENDING|CHANGES_REQUESTED|COMPLETE, defaultPENDING),completedAt(nullable timestamptz, set when the last level approves).currentLevelis never decremented — aREQUEST_CHANGESdecision keeps the same level active so the same approver re-reviews the HOD’s revision, rather than restarting the chain from level 1.DepartmentGoalComment(department_goal_comments) — mirrorsFollowUpNote’s shape.cycle(M:1,CASCADE),department(M:1,RESTRICT),postedByAdmin(nullable FK,SET NULL),content(text),approvalLevel(nullable smallint — set only when this row is a formal approve/reject decision echoed into the feed) anddecision('APPROVED'|'CHANGES_REQUESTED'|null—nullfor a general, non-decision comment). One table backs two distinct use cases: a formal per-level decision (always tied to a level) and a free-standing comment anyDEPARTMENT_GOALS_WRITEadmin can post regardless of whether a chain is configured — the HOD sees both in one chronological feed.
Indexes on department_goals: a composite (cycle_id, department_id), not two standalone single-column
indexes — getCurrentForMember (the hottest read path, hit on every load of a member’s or HOD’s goals page)
filters on both together, and the composite still fully serves the cycle_id-only queries (getGoalsForCycle,
getReport) via the leftmost-prefix rule, so a separate cycle_id index would only add write overhead for no
read benefit. A standalone department_id index is kept alongside it (not covered by the composite, since
department_id isn’t the leading column) so the RESTRICT FK on departments.id doesn’t force a full table
scan of department_goals every time an admin attempts to delete a department. church_rated_by_admin_id/
self_rated_by_member_id are deliberately left unindexed — neither Admin nor Member rows are ever
hard-deleted anywhere in this codebase (member deletion isn’t exposed at all, per this doc’s own policy; admins
are deactivated via isActive, never deleted), so the SET NULL cascade those FKs exist for can never actually
fire, and an index that backs a cascade check that never runs is pure overhead. department_goal_cycles itself
carries no index beyond its primary key — bounded, admin-created reference data (a handful of cycles per tenant
per year) stays small enough for the whole table’s lifetime that Postgres’s planner would prefer a sequential
scan over an index scan regardless, the same reasoning departments/venues already go unindexed on their own
non-PK columns.
Stage is computed, never stored (DepartmentGoalService.getEffectiveStage) — every guard and query goes
through this one function rather than comparing graceDeadline/endDate directly at the call site, since a
real instance of that exact bug (an isActive-style flag ANDed with a date check inconsistently across call
sites) already exists elsewhere in this codebase, in pledge.service.ts:
INACTIVE — cycle.isActive === false (blocks every write path, including both ratings — a
deactivated/cancelled cycle can't still be rated after the fact)
OPENING — today < graceDeadline (HOD writes/edits/removes goals)
IN_PROGRESS — graceDeadline <= today < endDate (church can correct a goal, audit-logged; no one else writes)
REVIEWED — today >= endDate (both ratings can be submitted, write-once each)
INACTIVE is deliberately distinct from REVIEWED, not a fifth date-derived bucket folded into it.
Authorization — department-scoped, not “is a lead somewhere.” DepartmentService gained two new methods
(assertIsDepartmentLead(memberId, departmentId, leadType?), getLeadRoles(memberId)) rather than reusing
the pre-existing getDepartmentIdForLead, which resolves “the” department for a member via an arbitrary
findOne and silently assumes a member leads at most one department — untrue, since DepartmentLead has no
uniqueness constraint on workerProfile, only on (department, leadType). Every HOD-write endpoint calls
assertIsDepartmentLead(memberId, departmentId, DepartmentLeadTypeEnum.HOD) scoped to the department in the
URL, not inferred.
Visibility rule for GET /department-goals/member/current — resolves every department relevant to the
caller (any department they lead, via DepartmentLead, plus their WorkerProfile.department/
secondaryDepartment) and returns one entry per department. A department the caller leads resolves via
DepartmentLead, not WorkerProfile — a Deputy-HOD’s own primary department can differ from the department
they actually lead, and the response must reflect the led department, not their profile’s. Per role: HOD/
Deputy-HOD see the goal list live from day one, including mid-draft during OPENING; a plain department
member sees nothing for that department until the cycle has locked (IN_PROGRESS or later) — goals are
withheld (goals: null), never partially shown. Every goal’s churchRating/selfRating (and their reasons)
are nulled out server-side unless both are non-null, for every role including the HOD who just submitted
one side — “neither sees the other’s score until both are in” is enforced uniformly, not by role; the HOD’s
own just-submitted rating is confirmed to them via the submit response itself, not by this endpoint reflecting
it back early.
cycle.hasApprovalChain (!!cycle.approvalChain?.length) — added after discuva-member’s IN_PROGRESS
banner was found to overpromise. getEffectiveStage flips IN_PROGRESS → REVIEWED purely on
today >= endDate, but submitSelfRating requires both REVIEWED and assertApprovalComplete (the
department’s approval chain, if one is configured, must be COMPLETE) — so a date-only “review begins
tomorrow” message can be wrong for a chain-configured cycle whose approval hasn’t finished. The member
frontend now checks this flag to soften that banner’s wording (see app/department-goals in that repo)
instead of promising a fixed date whenever a chain might still be pending.
Two controllers, same route-ordering rationale as Church Calendar’s admin/member split — mounted at
distinct base paths so the admin controller’s :id wildcard can’t swallow the member controller’s routes:
DepartmentGoalAdminController(department-goals,AdminGuard+DEPARTMENT_GOALS_READ/WRITE,@RequiresModule('department_goals')— deliberately not stacked withPlanGuard/@RequiresPlan, unlike Church Calendar’s own admin controller; see the plan-gating note below): cycle CRUD, the cross-department goal list, a goal correction endpoint (IN_PROGRESSonly, always audit-logged asDEPARTMENT_GOAL_CORRECTED), the church-rating endpoint, and the per-department report.DepartmentGoalMemberController(department-goals/member,JwtAuthGuard, same@RequiresModule): thecurrentread endpoint plus HOD-only goal CRUD, self-rating, goal-history (audit log filtered to that goal, scoped to the goal’s own department’s lead — “the HOD can see that history… not discover secondhand”), and the PDF export, all nested undercycles/:cycleId/departments/:departmentId/...so the department a write targets is always explicit in the URL rather than inferred from “the member’s one department.”
Plan gating — one deliberate deviation from the Church Calendar precedent. Church Calendar stacks
ModuleEnabledGuard and PlanGuard together. PlanGuard checks features.includes(required) only and
never consults Tenant.moduleOverrides, while ModuleEnabledGuard does — so a platform-admin comp override
for a non-Pro tenant would still 403 through PlanGuard alone. Since Department Goals has no numeric usage
cap to justify PlanGuard’s extra check (no @CountsTowardLimit use case), both controllers here use
ModuleEnabledGuard alone — plan membership is still resolved through it, just without the second guard’s
override-blind failure mode.
AdminPermission.DEPARTMENT_GOALS_READ/WRITE, a newDepartment Goalspermission group.KNOWN_MODULESkeydepartment_goals.PlanFeature.DEPARTMENT_GOALS, added to all four Pro plan variants’featuresbyAddDepartmentGoalsToProPlans1794081600000(same all-four-variants shape asAddChurchCalendarToProPlans).GrantDepartmentGoalsPermissions1797231600000backfillsdepartment_goals:read/writeonto every existing tenant’sSuperAdminrole, in the same migration wave as the table creation — not a later follow-up fix, the mistakeGrantPagesPermissionshad to correct after ship.
PDF export (PdfService.generateDepartmentGoalReport/drawDepartmentGoalReport) — one more draw* method
alongside the session/event/giving-statement reports already there, a single goals table (Goal / Self Rating /
Church Rating) for the HOD’s own department at the same visibility rules getCurrentForMember already applies
(a rating column reads — for exactly the same reason it would be null in the member API — not yet both
submitted — so the export can never leak a one-sided score either).
Frontend surfaces. discuva-admin gets a new top-level Department Goals nav entry (under People, next to
Departments) with three screens: a cycle list + create panel (app/department-goals/, same list+panel shape as
Games/Departments), a cycle detail page (per-department goal breakdown, the correction affordance in
IN_PROGRESS, the church-rating form in REVIEWED, and a “move grace deadline”/deactivate control), and a
report page reusing components/charts/bar-chart.tsx exactly as the attendance leaderboard does. discuva-member
gets a single new screen (components/layout/department-goals.tsx, linked as a new card from the existing
/department-summary page) showing every department relevant to the caller at their role’s visibility: a live
editable goal list for the HOD during OPENING (add/edit/remove, reusing the LeaveCard-style
editable-vs-read-only split), a locked read-only list during IN_PROGRESS, and the per-goal reveal plus a
self-rating form once REVIEWED. The PDF download button ports discuva-admin’s existing blob-download pattern
(URL.createObjectURL + <a download>) into discuva-member for the first time — that pattern didn’t exist
there before this.
discuva-member polish pass, done directly against the first version of this screen: the delete-goal
confirmation used window.confirm() in the initial cut — replaced with the app’s shared ConfirmModal
(components/ui/confirm-modal.tsx), the same component the Games/front-desk leave-guard work already
established as the one confirmation pattern this app uses. The self-rating input was a <select> — replaced
with a row of five tappable number buttons (thumb-friendly touch targets, no native picker chrome). Every
mutation (add/edit/remove a goal, submit a rating) previously refetched GET .../member/current through the
same isLoading flag the page’s initial load uses, which re-collapsed the whole page back to its loading
skeleton after every small action — fixed by gating the skeleton on isLoading && !data instead of isLoading
alone, so a background refetch just swaps in fresh data over what’s already on screen. Added a days-remaining
line under the stage badge during OPENING (“Goal-writing closes in N days”) and IN_PROGRESS (“review begins
in N days”) — the cycle is inherently time-boxed and the UI gave no sense of how much of the window was left.
Header redesigned to match Department Summary — reported as feeling “disconnected.” The screen originally
opened with a plain flat header (small back arrow, text eyebrow, title), modeled after front-desk-session.tsx
— the wrong precedent: that screen is a live-operating console, deliberately minimal since you’re mid-task, not
a browse/manage destination screen like this one. Landing here immediately after Department Summary’s full-bleed
hero (the only way into this screen — via the card there) read as leaving the department-management flow
entirely. Now uses the same hero treatment as department-summary.tsx (h-[40vh] image, dark overlay, overlaid
back button and title) — and deliberately reuses Department Summary’s own image (teamwork-hands-unity) rather
than a new asset, since the shared image is what actually reads as “still the same flow,” not merely “also has a
hero.”
Same fix applied to games-history.tsx — an app-wide audit for this exact pattern (a browse/destination
screen, one tap from an already-hero’d screen, itself flat) found one other real instance: Game History, reached
from Games’ own join screen (games-join.tsx, hero key game-backdrop). Now shares that same image, for the
same reason. Everything else without a hero survived the audit as legitimately flat by an already-consistent
convention, not an oversight — detail pages drilled into from an already-hero’d list (announcement-detail.tsx,
event-detail.tsx, sermon-detail.tsx) and live/operational “in the moment” screens (front-desk-session.tsx,
game-session.tsx, my-live-assignment.tsx, small-group-attendance.tsx) don’t get one, deliberately — a
detail view’s own content is the point, not a repeated generic photo, and an operational screen mid-task needs
its vertical space for what’s actually live, not a static image.
order-of-service.tsx’s hero was missing its title in the empty state. An earlier fix here correctly removed
a “This Week” placeholder title that read as an answer (“here’s this week’s service”) even when nothing was
actually scheduled, contradicting the “No service scheduled” message rendered just below it — but the fix
removed the <h1> entirely rather than giving it a neutral fallback, leaving the hero with only its small
eyebrow line and nothing else whenever programme has no name, thinner than every other hero’d page’s
consistent eyebrow-plus-title pair. Fixed by falling back to the eyebrow’s own text (“Order of Service”) as the
title instead of hiding it — asserts nothing about whether a service is scheduled, but still fills the slot.
discuva-admin: the New Cycle button gave no reason for staying disabled on an invalid date order. Reported
live: entering a graceDeadline before startDate (or an endDate before graceDeadline) left “Open Cycle”
permanently greyed out with zero explanation — nothing distinguished “you haven’t finished the form” from “what
you entered is contradictory.” Fixed with the same blockReason pattern church-calendar/page.tsx’s own
date-range form already established: a specific message (“The opening window can’t close before it starts…”)
rendered as an amber inline hint above the button, replacing the bare boolean valid check. Also added min
attributes to the Opening Window Closes / Cycle Ends date inputs (min={draft.startDate} /
min={draft.graceDeadline}) so the browser’s own date picker discourages the invalid combination before the
admin even finishes picking, mirroring the minDate/maxDate props Church Calendar’s date-range picker already
uses for the same purpose.
discuva-member: two real gaps found and fixed in the More grid. First, an inconsistency — Leave Request and
Evangelism are worker-gated tiles (components/layout/profile.tsx’s ministryTiles, only rendered when
isWorker) exactly like Prayer Roster, but only Prayer Roster carried the badge: "Workers" label communicating
that restriction; the other two now do too. Second, and more substantial: plain department members had no way
to reach Department Goals at all. The only entry point was the card on Department Summary — itself one of the
leadershipTiles, gated isHod (which, per AuthService.getProfile, is actually true for both HOD and
Deputy-HOD, since it’s departmentLeadRepo.exists({ workerProfile }) with no leadType filter — so Deputy-HODs
already had a path). A regular department member has neither role, so despite the backend (getCurrentForMember)
and the page itself both already handling a plain-member “read-only, revealed at the right stage” view correctly,
there was no door into it. Fixed by adding “Department Goals” as its own tile directly in ministryTiles (open
to every worker, badge: "Workers", moduleKey: "department_goals") — the HOD-only write tools (Dept.
Attendance/Summary) stay exactly where they were, under Leadership.
help.tsx’s Department Goals FAQ category was still isHod-only, gating it behind a role that no longer
matches who can actually reach the feature — broadened to isWorker, badge changed to “Workers” to match the
tile, and a new Q&A (“I’m not the HOD — what do I see?”) added specifically for the plain-member read-only
experience, alongside the existing HOD-facing questions (same “one category, mixed relevance per question”
shape the pre-existing Evangelism category already uses).
discuva-admin’s own ConfirmModal (components/ui/confirm-modal.tsx) is now applied everywhere, not just the
five screens it already covered — the Department Goals work above surfaced that this app had a working shared
confirm-dialog component that most of its own destructive actions still bypassed in favor of window.confirm().
Swept and converted every remaining instance: Games (delete question, end session — both the list page and the
detail page), Pages (unpublish, delete), Sermons (delete), Church Calendar (delete), and Forms (delete). Each
conversion follows the same shape already established by facility-rental/service-programme/etc.: a
confirm* state (boolean or holding the pending record) gates the modal’s render, the original handler drops
its window.confirm() guard and becomes the onConfirm callback, and the button that used to call the handler
directly now just opens the confirm state. window.confirm() no longer appears anywhere in discuva-admin.
app/department-goals/layout.tsx — every feature directory in discuva-admin needs its own layout.tsx
wrapping <Shell> (the sidebar, topbar, and help button); the three page files were initially added without one,
so the route rendered with no chrome at all. Fixed by copying the exact one-line pattern app/games/layout.tsx/
app/departments/layout.tsx already use — Next.js layouts apply to everything nested under them, so this single
file covers all three Department Goals routes.
Help/FAQ coverage. discuva-admin’s contextual help (components/layout/help-system.tsx, the ? button’s
per-page tips) gained a /department-goals entry alongside Departments/Games/Church Calendar, plus a one-word
addition to the People section’s welcome-tour blurb. discuva-member’s Help page
(components/layout/help.tsx) gained a new “Department Goals” FAQ category, gated visible: isHod && isModuleEnabled("department_goals") — deliberately its own category rather than folded into the existing
“Department Leadership” one, since Dept. Summary/Finance Requests/Pastor Feedback aren’t module-gated at all and
folding a Pro-plan-gated feature’s FAQ into an always-visible category would show HODs on non-Pro tenants
questions about a feature they can’t reach.
Optional hierarchical approval chain + comments (per-cycle, opt-in). A church can configure an ordered chain
of up to 3 admin approvers on a cycle (approvalChain); a department’s goals then must pass through every level,
in order, before they can be rated. This is layered on top of the stage machine above, never a replacement for
it — see DepartmentGoalApprovalService:
- Blocking is per-department, not per-cycle.
getEffectiveStagestays a pure function of dates, shared by every department, completely unchanged. A chain-gated department’s goals additionally can’t be rated (submitChurchRating/submitSelfRatingboth still requireREVIEWEDfirst, unchanged) until that specific department’sDepartmentGoalApproval.statusisCOMPLETE— a slow approver on one department never freezes any other department or the cycle’s own calendar. - The HOD keeps write access past
OPENINGfor any department whose approval isn’t yetCOMPLETE— this is what makes “reopen editing after changes are requested” work even after the grace deadline has passed.createGoal/updateGoalAsHod/deleteGoalAsHodeach call the new privateassertGoalWritable, which branches on whethercycle.approvalChainis configured; for a cycle with no chain, behavior is byte-for-byte the sameOPENING-only gate as before. - Decisions (
DepartmentGoalApprovalService.decide) — only the admin assigned to the department’scurrentLevelmay act (403 otherwise), and that admin may not be the department’s own registered HOD (403, mirrors Finance Request’s self-approval block).REQUEST_CHANGESrequires a non-blank comment, setsstatus = CHANGES_REQUESTEDwithout advancing the level, and writes a decision-echoDepartmentGoalComment.APPROVEadvancescurrentLevel(or setsstatus = COMPLETEat the last level) and also echoes a comment. Once the HOD saves any edit whileCHANGES_REQUESTED, status silently flips back toPENDINGat the same level (onHodGoalWrite) — the same approver re-reviews the revision. - Chain mutability — once any department under a cycle has a recorded decision, the chain’s structure
(which level numbers exist) is locked (
assertChainMutable); reassigning which admin holds an existing level stays allowed at any time, so a deactivated/departed approver doesn’t permanently stall a department. - General comments are independent of the chain — any
DEPARTMENT_GOALS_WRITEadmin can post one on any department at any time (even with no chain configured at all), as an alternative to the existingcorrectGoaldirect-edit flow. - Push notification on every decision and comment —
DepartmentGoalApprovalServicefiresNotificationDispatchService.notifyMember(push-only, no email leg yet) to the department’s HOD and Deputy-HOD after everydecide()call and everyaddComment()call, gated by the newEmailCategory. DEPARTMENT_GOAL_ACTIVITYcategory toggle (same per-tenant on/off mechanism every other notification type in this codebase already uses — a church can disable it from the existing category-settings UI). - Admin-facing correction history —
getGoalHistoryForAdminis a new, parallel method next to the existing HOD-onlygetGoalHistory(not a relaxation of it — zero behavior change to the member-facing path), exposed atGET /department-goals/cycles/:id/goals/:goalId/history. - No new
AdminPermissionwas introduced — the picker/decide/comment endpoints reuse the existingDEPARTMENT_GOALS_READ/WRITE.GET /department-goals/cycles/admin-optionsis deliberately scoped toDEPARTMENT_GOALS_WRITErather than reusing theADMIN_READ-gated admin-user-list endpoint, so an admin who can manage goal cycles isn’t also required to hold admin-management access just to pick an approver.
Bulk import (DepartmentGoalImportService/DepartmentGoalImportController, src/department-goal/). Lets an
admin upload goals on behalf of a department’s HOD — e.g. the admin emails the HOD a downloaded template, the HOD
fills it offline, and the admin uploads the completed file — rather than requiring the HOD to type each goal into
discuva-member. Mirrors MemberImportService’s two-phase preview → commit shape (LimitedFileInterceptor,
ExcelService.buildWorkbook, ExcelJS parsing) rather than introducing a new pattern:
- Scoped per cycle and per department (
.../cycles/:cycleId/departments/:departmentId/bulk-import/...) — one upload always targets exactly one department’s goals for one cycle, so the template has no Department column, only the same three fields the member-app goal form itself captures:KPI(title, required),KPI Description(description),Timeline to Achieve Target(timelineToAchieve). - Reuses
DepartmentGoalService.assertGoalWritable(now public) at both preview time and commit time — the exact same writability rule a HOD’s owncreateGoalcall is gated by (cycle must beOPENING, or, for a chain-configured cycle, any stage before that department’s approval reachesCOMPLETE). This is what stops an admin from bulk-writing goals into a cycle that’s already closed for that department, and the commit-time recheck catches a cycle that closed in the gap between preview and commit. - Each row is validated with the existing
CreateGoalDto(class-validator) — a preview response includes every row (valid or not) with itserrors: string[], so the admin sees exactly what’s wrong before committing; commit only creates goals for rows with zero errors and reports the rest back asfailedRows. - Persisted as two new tables,
department_goal_import_jobs/department_goal_import_rows(job holds cycle/department/status/counts/createdBy; each row holds its raw parseddataasjsonbpluserrors, decoupled fromDepartmentGoal’s own columns so schema drift doesn’t break historical import rows — same reasoning asMemberImportRow). - Every goal created this way gets
DepartmentGoal.createdByAdminset (a new nullable FK,SET NULLon delete) — purely an audit marker. The goal behaves identically to one the HOD entered directly: samePENDING-by-default approval flow, and the HOD can still edit or delete it from discuva-member for as long asassertGoalWritablesays the department’s goals remain open (i.e. right up to the cycle’s grace deadline, or later still if an approval chain is configured and not yetCOMPLETE). - A job can only be committed once (
BadRequestExceptionon a second commit attempt) — there’s no update-in-place; re-uploading a corrected file starts a new job. GoalView(the shape returned byGET /department-goals/member/current) now includescreatedByAdmin: booleanso discuva-member can flag admin-uploaded goals for the HOD’s attention rather than presenting them identically to self-written ones.
| Method | Route | Auth | Notes |
|---|---|---|---|
| POST | /department-goals/cycles |
AdminGuard (DEPARTMENT_GOALS_WRITE) | Create a cycle |
| GET | /department-goals/cycles |
AdminGuard (DEPARTMENT_GOALS_READ) | List all cycles — unpaginated |
| PATCH | /department-goals/cycles/:id |
AdminGuard (DEPARTMENT_GOALS_WRITE) | Edit dates (incl. moving graceDeadline anytime — re-validated), toggle isActive, set/reassign approvalChain |
| GET | /department-goals/cycles/admin-options |
AdminGuard (DEPARTMENT_GOALS_WRITE) | Active admins as {id, name, email}[], to populate an approval-chain picker |
| GET | /department-goals/cycles/:id/goals |
AdminGuard (DEPARTMENT_GOALS_READ) | Cross-department goal list for the cycle |
| PATCH | /department-goals/cycles/:id/goals/:goalId |
AdminGuard (DEPARTMENT_GOALS_WRITE) | Church correction — IN_PROGRESS only, audit-logged |
| POST | /department-goals/cycles/:id/goals/:goalId/church-rating |
AdminGuard (DEPARTMENT_GOALS_WRITE) | Write-once, REVIEWED only, and (if a chain is configured) only once that department’s approval is COMPLETE |
| GET | /department-goals/cycles/:id/goals/:goalId/history |
AdminGuard (DEPARTMENT_GOALS_READ) | Admin-facing correction history — same audit data as the HOD-only member route, no department-lead gate |
| GET | /department-goals/cycles/:id/report |
AdminGuard (DEPARTMENT_GOALS_READ) | Per-department avg self/church score + the gap |
| GET | /department-goals/cycles/:id/approvals |
AdminGuard (DEPARTMENT_GOALS_READ) | Bulk per-department approval status for the cycle |
| GET | /department-goals/cycles/:cycleId/departments/:departmentId/bulk-import/template |
AdminGuard (DEPARTMENT_GOALS_WRITE) | Downloads an .xlsx template (KPI, KPI Description, Timeline to Achieve Target columns) — see Bulk Import below |
| POST | /department-goals/cycles/:cycleId/departments/:departmentId/bulk-import/preview |
AdminGuard (DEPARTMENT_GOALS_WRITE) | Multipart file upload — parses + validates each row, returns a job + per-row errors without writing any goals yet |
| GET | /department-goals/cycles/:cycleId/departments/:departmentId/bulk-import/:jobId |
AdminGuard (DEPARTMENT_GOALS_WRITE) | Re-fetch a previewed job and its rows |
| POST | /department-goals/cycles/:cycleId/departments/:departmentId/bulk-import/:jobId/commit |
AdminGuard (DEPARTMENT_GOALS_WRITE) | Creates a DepartmentGoal for every error-free row; returns {createdCount, failedRows} |
| POST | /department-goals/cycles/:id/departments/:departmentId/approval-decisions |
AdminGuard (DEPARTMENT_GOALS_WRITE) | {decision: 'APPROVE'|'REQUEST_CHANGES', comment?} — only the department’s current-level approver may call this |
| GET | /department-goals/cycles/:id/departments/:departmentId/comments |
AdminGuard (DEPARTMENT_GOALS_READ) | Full comment + decision feed for the department, real admin names |
| POST | /department-goals/cycles/:id/departments/:departmentId/comments |
AdminGuard (DEPARTMENT_GOALS_WRITE) | {content} — general comment, always available regardless of chain config |
| GET | /department-goals/member/current |
JwtAuthGuard (member+worker) | Every department relevant to the caller, at their role’s visibility |
| POST | /department-goals/member/cycles/:cycleId/departments/:departmentId/goals |
JwtAuthGuard (HOD only) | OPENING only |
| PATCH | /department-goals/member/cycles/:cycleId/departments/:departmentId/goals/:goalId |
JwtAuthGuard (HOD only) | OPENING only, frozen once rated |
| DELETE | /department-goals/member/cycles/:cycleId/departments/:departmentId/goals/:goalId |
JwtAuthGuard (HOD only) | OPENING only, frozen once rated |
| POST | /department-goals/member/cycles/:cycleId/departments/:departmentId/goals/:goalId/self-rating |
JwtAuthGuard (HOD only) | Write-once, REVIEWED only |
| GET | /department-goals/member/cycles/:cycleId/departments/:departmentId/goals/:goalId/history |
JwtAuthGuard (dept. lead only) | Audit log, filtered to DEPARTMENT_GOAL_CORRECTED for this goal |
| GET | /department-goals/member/cycles/:cycleId/departments/:departmentId/approval |
JwtAuthGuard (dept. lead only) | This department’s approval status — null if no chain configured or no goal written yet |
| GET | /department-goals/member/cycles/:cycleId/departments/:departmentId/comments |
JwtAuthGuard (dept. lead only) | Read-only comment + decision feed (no member-facing POST — comments stay admin-authored) |
| GET | /department-goals/member/cycles/:cycleId/departments/:departmentId/pdf |
JwtAuthGuard (any member of the department) | application/pdf download — HOD, Deputy-HOD, or a plain worker/member (primary or secondary department), not HOD-only; exporting what’s already shown on-screen isn’t a write action |
Social Media Module (src/social-media/)
Central, tenant-scoped connector framework for cross-posting to a church’s social accounts from one compose box.
All the shared OAuth/media/scheduling infrastructure is real and fully wired — platform-level app credentials,
per-tenant encrypted token storage, the connect/callback flow, real multi-file upload, per-placement validation,
retention, and scheduled publishing. Facebook, Instagram, and YouTube have real publishers
(FacebookGraphPublisher/InstagramGraphPublisher, backed by the Meta Graph API; YouTubePublisher, backed by
the YouTube Data API v3 — see below for both); X and TIKTOK still resolve to NotConnectedPublisher (or
PlatformDisabledPublisher if a platform-admin has switched it off) via SocialPublisherRegistry, which always
fails honestly rather than pretending to succeed. X’s API dropped its free tier entirely in February 2026
(pay-per-use, ~$0.015–$0.20 per post) — wiring it in is a pricing decision (who absorbs that per-post cost:
Discuva or the church?) as much as an engineering one, not scheduled yet. TIKTOK’s Content Posting API restricts
any unaudited app to SELF_ONLY (private) visibility until TikTok completes its own audit, so there’s nothing
meaningful to test until that’s done — also not scheduled yet. Wiring in either is the same shape either way — see
the publisher extension point below; SocialPostService and every controller stay unchanged when that lands.
Entities:
SocialAccount(social_accounts) —platform(SocialPlatform:FACEBOOK/INSTAGRAM/X/YOUTUBE/TIKTOK),displayName,externalAccountId(nullable — Page/Channel/user id, resolved during the OAuth exchange),isConnected,connectedAt/connectedBy. Also carries the OAuth token itself, allselect: falseso a normalfind()never returns them:accessTokenEncrypted,refreshTokenEncrypted(nullable — not every platform issues one),tokenExpiresAt,scope. Encrypted viaEncryptionService(AES-256-GCM), same convention asTenantCommunicationProviderConfig.credentialsEncrypted.SocialPost(social_posts) —content,status(SocialPostStatus:DRAFT→SCHEDULED/PUBLISHING→PUBLISHED/PARTIALLY_PUBLISHED/FAILED),createdBy(nullable FK →admins,SET NULL),publishedAt,scheduledFor(nullable — set only whileSCHEDULED). No longer hasimageUrl; seeSocialPostMedia.SocialPostTarget(social_post_targets) — one row per(post, account, placement), so a single post’s per-platform and per-placement outcome is tracked independently:status(SocialPostTargetStatus:PENDING/SUCCESS/FAILED),placement(SocialPlacement:FEED/STORY/REEL— Instagram Stories/Reels and YouTube Shorts are genuinely different publish surfaces from a feed post, not just a platform distinction; one connected account can have multiple targets across placements for the same post),errorMessage,publishedAt,externalPostId(nullable — the platform’s own id for the published post/video, set from a successfulPublishResult; what a stats fetch or any future “open this on the platform” link looks up). Also carries the composer’s per-target customization:contentOverride(nullable text —nullmeans this target still sharesSocialPost.content) andmediaFocalX/mediaFocalY(nullable numeric, 0-1 — a click-to-crop-focus point, only meaningful forSTORY/REEL; bothnullmeans “let Cloudinary’sg_autocontent-aware cropping choose,” not “no crop” — seeSocialMediaCropServicebelow).SocialPostMedia(social_post_media) — real Cloudinary-backed attachments, replacing the old free-textimageUrl.url,publicId(needed to delete the actual asset, not just the row),mimeType,sizeBytes,width/height/durationSeconds(nullable, used bySocialMediaValidationService),order.SocialPlatformApp(social_platform_apps, public schema, control-plane) — one row perSocialPlatformholding Discuva’s own OAuth app credentials (clientId,clientSecretEncrypted,redirectUri,scopes). Unlike email/SMS BYOK, a tenant cannot register their own Meta/Google/X developer app, so this is platform-owned, not per-tenant.isActiveis the platform-admin kill switch — see below.
OAuth connect + callback flow:
GET /social-media/accounts/:id/authorize-url(tenant-authenticated,AdminGuard) —SocialOAuthConnectServicelooks up the account’s platform, confirms itsSocialPlatformAppis registered and active, encodes{accountId, tenantId, nonce, issuedAt}into astatetoken viaOAuthStateService(AES-256-GCM encrypt — the auth tag makes it tamper-evident, doubling as OAuth’s CSRF protection without a separate HMAC/JWT; 10-minute expiry), and returns the platform’s authorize URL for the frontend to redirect to.GET /v1/integrations/social/:platform/oauth/callback—@Public(), added toTenantMiddleware’s exclude list (src/tenant/tenant.module.ts— do not remove this without also removing the exclude, the documented failure mode is a silent 404 in production, previously hit for the YouTube WebSub callback). Called directly by Meta/Google/X’s redirect, which carries no tenant subdomain —stateis decoded to recovertenantId/accountId, the tenant’sschemaNameis looked up, and the rest runs insiderunInTenantContext(...)(same pattern as the giving-checkout webhook): exchange the code for tokens, encrypt and store them on the matchingSocialAccount, setisConnected/connectedAt, then redirect the browser back to discuva-admin (ADMIN_LOGIN_URL+/social-media?connected=<platform>or?error=<reason>). Never throws past the top level — the caller is a browser mid-redirect, not an API client — failures are logged server-side and surfaced to the browser as a generic?error=connection-failed.
The publisher extension point (publisher/social-platform-publisher.interface.ts): SocialPlatformPublisher
is a one-method interface (publish(account, post, placement): Promise<{success, error?, externalPostId?}>),
resolved per SocialPlatform by SocialPublisherRegistry. placement is the specific SocialPostTarget’s
placement (FEED/STORY/REEL) — a single account can have multiple targets across placements for the same
post, so this is the only way a publisher knows which one a given call is for; a publisher that doesn’t support a
placement SocialMediaValidationService allows should fail that call explicitly via PublishResult.error, not
silently substitute FEED. On every resolve() call the registry also checks
PlatformSocialAppService.isPlatformDisabled(platform) — if a platform-admin has switched a platform off, it
returns PlatformDisabledPublisher (distinct wording from NotConnectedPublisher: “temporarily disabled by
Discuva,” not “this church hasn’t set this up”) instead of whatever publisher is registered, without touching any
tenant’s already-stored tokens. Wiring in a real platform means implementing this interface plus a matching
SocialTokenRefresher (token/, for transparent access-token renewal) and SocialOAuthExchanger (oauth/, for
the authorize-URL/code-exchange mechanics) — SocialPostService, the controllers, and SocialTokenResolverService
never change.
Meta (Facebook/Instagram) implementation (platform/meta/meta-graph-api.service.ts) — MetaGraphApiService
holds the Graph API mechanics shared by both FacebookOAuthExchanger/InstagramOAuthExchanger and
FacebookGraphPublisher/InstagramGraphPublisher, since Instagram Business publishing runs on the same Meta App,
the same Business Login OAuth dialog, and the same Page access token as Facebook — only which node you call (a
Page vs. its linked IG Business Account) differs:
resolvePageAccessToken— code → short-lived user token → long-lived user token (fb_exchange_token) →GET /me/accountsfor the Page(s) granted. Exactly one Page is the expected/supported outcome (a church connects one Page); zero or multiple both throw a clear, actionable error rather than guessing which one to use. The Page token returned this way doesn’t expire in practice and Meta issues norefresh_tokenfor it —FacebookOAuthExchangerandInstagramOAuthExchangerboth omitexpiresInSeconds/refreshTokenfrom theirOAuthExchangeResult, sotokenExpiresAtstaysnullandSocialTokenResolverServicenever attempts a refresh (both platforms stay registered toNoRefresherAvailable— nothing to implement there).getInstagramBusinessAccountId— one extra call (GET /{pageId}?fields=instagram_business_account) is all that separatesInstagramOAuthExchangerfromFacebookOAuthExchanger;externalAccountIdends up being the IG Business Account id instead of the Page id, but the stored token is the same Page access token either way.publishToFacebookPage(pageId, pageAccessToken, content, media, placement)— onlyFEEDis implemented; any other placement throws immediately, before any request is made (SocialMediaValidationService’s constraints table only definesFEEDfor Facebook today, so this isn’t reachable yet, but the rejection is real, not assumed). FEED: text-only →/feed; single image →/photos; single video →/videos(checked in that preference order if a post somehow carries both). No multi-image gallery support yet — matches what validation actually checks today (primaryVideo, not a gallery).publishToInstagram(igUserId, pageAccessToken, content, media, placement)— always two calls: create a media container (/media), then/media_publish. What differs byplacementis the container’smedia_type:STORY→'STORIES'(image or video);FEED/REELare, as far as this API is concerned, the same call — a video posted via the Content Publishing API always processes as a Reel (media_type: 'REELS') even when it also appears in the normal feed, and an image needs nomedia_typeat all (defaults toIMAGE) in either placement. Video containers process asynchronously on Meta’s side, so a bounded poll (status_codeviaGET /{containerId}, 3s interval, 20 attempts) waits forFINISHEDbefore publishing rather than racing Meta’s own processing. Stories don’t visibly render thecaptionfield, but it’s passed through anyway rather than silently dropping content the admin typed.
Every failure path in both publishers resolves to {success: false, error} — never throws. This matters because
SocialPostService.publish() calls publisher.publish() with no try/catch; a thrown error there would abort
every remaining target’s publish attempt, not just the one platform that failed, breaking the documented “one
platform failing never blocks the others” guarantee.
Not live-tested against a real Meta account from this environment (no outbound network access to
graph.facebook.com in the sandbox this was built in) — written correctly against Meta’s documented Graph API
contract and covered by unit tests mocking fetch, but the first real connect→publish run against an actual
connected Page/Instagram account is the real end-to-end verification.
YouTube implementation (platform/youtube/youtube-api.service.ts) — YouTubeApiService holds the Google
OAuth2 + YouTube Data API v3 mechanics for YouTubeOAuthExchanger, YouTubePublisher, and (unlike Meta)
YouTubeTokenRefresher:
buildAuthorizeUrlsetsaccess_type=offlineandprompt=consent— without both, Google only issues arefresh_tokenon a user’s very first-ever consent for the app; a later reconnect after revoking access would silently come back with norefresh_tokenat all otherwise, and there’d be nothing forSocialTokenResolverServiceto renew against once the short-livedaccess_tokenexpires.resolveChannelmirrorsresolvePageAccessToken’s “exactly one expected” pattern — a Google account can have multiple channels/brand accounts, same as a Facebook user managing multiple Pages; zero or multiple both throw a clear, actionable error.- No URL-passthrough upload. Unlike Meta’s Graph API (
file_url/image_url, Meta fetches the asset itself), the YouTube Data API has no such option —publishVideodownloads the attachment from its Cloudinary URL into memory, then streams those bytes to Google via the resumable upload protocol (POST .../videos?uploadType=resumableto start a session and get aLocationheader, thenPUTthe raw bytes to that URL). Loading the full file into memory rather than piping the download directly into the upload is a real, deliberate simplification — correct and fine at the scale a church’s social posts run at (the existing 200MB attachment cap), but worth knowing about if that cap ever grows. REELgets"#Shorts"appended to the description — the documented, best-effort signal for YouTube’s Shorts shelf, not a guaranteed classification; YouTube’s own aspect-ratio/duration heuristics still decide.- Google access tokens genuinely expire (~1 hour), unlike a Meta Page token —
YouTubeTokenRefresheris the first real (non-NoRefresherAvailable)SocialTokenRefresherimplementation.SocialTokenRefresher.refresh()only receives the bare refresh token, not the platform app/clientSecreta Google refresh request needs, so it looks its ownSocialPlatformApprow up directly viaPlatformSocialAppServicerather than requiring an interface change every other platform would have to accommodate too — it’s already irreducibly YouTube-specific by being this class at all. contentmaps onto YouTube’s separatetitle/descriptionfields (Discuva’s data model has only one caption field) astitle = content.slice(0, 100)(YouTube’s title cap),description = content(+ "#Shorts"forREEL).
Also not live-tested from this environment, for the same no-outbound-network reason as Meta — written against
Google’s documented OAuth2 and YouTube Data API v3 contracts, covered by unit tests mocking fetch.
SocialTokenResolverService — every publisher calls getValidAccessToken(accountId) instead of touching
SocialAccount’s encrypted columns directly. Takes an id, not an entity, since the token columns are select: false and a SocialAccount loaded via a normal relation (e.g. post.targets[].account) never carries them.
Decrypts and returns the token if not expired (60s safety margin); if expired and a refresh token exists, resolves
that platform’s SocialTokenRefresher (YouTubeTokenRefresher for YOUTUBE; NoRefresherAvailable, which throws,
for every platform with no real refresh flow — Meta Page tokens included, since they don’t expire) and persists the
renewed token.
Media validation (SocialMediaValidationService) — keyed on (platform, placement), not platform alone,
informed by researched per-platform specs (image/video size & duration caps, caption length, max image count).
validate(media, targets) takes content per target entry (not one shared param) — a target with its own
contentOverride validates against that override, not SocialPost.content, so two targets in the same call can
have entirely different caption lengths. Two-tier model: errors (wrong content type for the placement, over a
hard size/duration/caption limit) block that specific target before it ever reaches its publisher; warnings
(e.g. an Instagram Reel over the ~3-minute “ideal” length) surface without blocking. Enforced inside
SocialPostService.publish(), not just a frontend nicety — a target with unresolved errors is marked FAILED
with the validation message, and its publisher is never called. getConstraints() returns the same table as
JSON — GET /social-media/constraints exposes it so the composer can show a live per-target character counter
against the exact numbers enforced at publish time, without a round-trip per keystroke.
Per-target customization — override and crop. Most targets share SocialPost.content and its media
untouched; two independent, opt-in per-target adjustments exist for when a platform’s constraints don’t fit the
shared version:
contentOverride(PATCH /social-media/posts/:id/targets/:targetId/override, body{contentOverride: string | null},DRAFTposts only) — a target-specific caption, e.g. a shortened version for X’s 280-char limit while Facebook/Instagram keep the full text.nullexplicitly clears it, reverting to the shared content. Can also be set at creation time viaCreateSocialPostDto.targets[].contentOverride.- Crop focal point (
PATCH /social-media/posts/:id/targets/:targetId/focal-point, body{x, y: number | null},DRAFTposts only, both set or cleared together) — only meaningful forSTORY/REEL.SocialMediaCropServicecrops to9:16(the one universal, strict requirement both placements share across every platform that supports them —FEEDis deliberately never cropped, since no platform enforces a single “correct” feed aspect ratio the way Stories/Reels do) using Cloudinary’sg_autocontent-aware/saliency cropping (core product, no add-on) by default, org_xy_centerat the storedx/yif a focal point is set.x/yare normalized (0-1) floats — Cloudinary accepts gravity offsets as float percentages directly, so a click position on the composer’s rendered preview maps straight through with no pixel-dimension math on either side. The transformation is inserted into the existing Cloudinary delivery URL as a path segment (.../upload/c_fill,ar_9:16,g_auto/...) — no re-upload, no second stored asset per placement.
SocialPostService.publish() resolves both — target.contentOverride ?? post.content and
SocialMediaCropService.resolveMediaForPlacement(post.media, target.placement, focalPoint) — exactly once per
target, before validation and before calling that target’s publisher. A publisher never sees SocialPost/
SocialPostTarget directly, only the already-resolved content: string and media: SocialPostMedia[] (see
SocialPlatformPublisher’s own comment) — so a publisher can’t forget to apply an override or a crop, and
resolution logic lives in exactly one place regardless of how many platforms get wired in later.
Scheduled publishing — POST /social-media/posts/:id/schedule ({scheduledFor: ISO string}) sets status = SCHEDULED and adds a delayed job to the social-post-publish Bull queue (jobId = the post’s own id, both to
prevent double-scheduling and so cancelSchedule can find it again without a separate stored column). When the
delay elapses, SocialPostPublishProcessor enters the job’s tenant context (runInTenantContext, envelope carried
via buildJobEnvelope) and calls the exact same SocialPostService.publish() “Publish Now” calls — scheduling
only decides when that call happens, there is no second publish path. POST /social-media/posts/:id/schedule/cancel removes the pending job and reverts the post to DRAFT.
Draft media retention (SocialMediaRetentionScheduler) — daily sweep (@Cron('0 3 * * *')) across every
active tenant: any DRAFT-status post whose updatedAt is older than a configurable window
(PlatformSettingKey.SOCIAL_MEDIA_DRAFT_RETENTION_DAYS, default 30, platform-admin adjustable via the existing
PlatformSettingsService) has its SocialPostMedia rows and their Cloudinary assets deleted. SCHEDULED and
published posts are never touched — only abandoned drafts age out. Closes a gap the researched incumbents
(Buffer/Hootsuite/Later) don’t document clearly.
Publish semantics (SocialPostService.publish): every target is attempted independently — one platform (or
validation) failing never blocks the others. The post’s overall status is derived from how many targets actually
succeeded: FAILED if none did, PUBLISHED if all did, PARTIALLY_PUBLISHED otherwise. publishedAt on the post
is set whenever at least one target succeeded. A successful PublishResult.externalPostId (the platform’s own id
for the post/video) is persisted onto the target as externalPostId — a failed republish attempt leaves a prior
externalPostId untouched rather than clearing it.
Stats extension point (stats/social-stats-fetcher.interface.ts) — SocialStatsFetcher is a one-method
interface (getStats(account, externalPostId): Promise<PostStats>), resolved per SocialPlatform by
SocialStatsFetcherRegistry, same shape as the publisher/exchanger/refresher extension points.
GET /social-media/posts/:id/targets/:targetId/stats (SocialPostService.getTargetStats) resolves a target’s
platform fetcher and returns whatever it reports; throws BadRequestException if the target has no
externalPostId yet (never published). YouTubeStatsFetcher is the only real implementation today — it
calls YouTubeApiService.getVideoStats (videos.list?part=statistics), returning viewCount/likeCount/
commentCount only (dislikeCount has been private since December 2021; favoriteCount is permanently 0). This
is deliberately the Data API v3’s own statistics, not the separate YouTube Analytics API
(youtubeAnalytics/v2) — that’s a genuinely different product (its own scope yt-analytics.readonly, its own
base URL, enabled separately in Google Cloud Console) needed for anything richer: watch time, audience retention,
traffic sources. Not wired in — a deliberate, discussed scope decision, not an oversight. Facebook/Instagram stats
would reuse the same Graph API MetaGraphApiService already talks to (different permissions —
pages_read_engagement, instagram_manage_insights — not a separate product the way YouTube’s Analytics API is),
but no FacebookStatsFetcher/InstagramStatsFetcher exists yet; both platforms resolve to NoStatsAvailable
(throws) via the registry, same honest-failure posture as NotConnectedPublisher.
Deleting a post is only allowed while DRAFT or fully FAILED — a PUBLISHED/PARTIALLY_PUBLISHED/
PUBLISHING post’s target history is kept, not deletable, since it’s the record of what was actually attempted.
| Method | Route | Auth | Notes |
|---|---|---|---|
| POST | /social-media/accounts |
AdminGuard (SOCIAL_MEDIA_WRITE) | Register an account to post to; isConnected is always false on create — connecting is a separate step |
| GET | /social-media/accounts |
AdminGuard (SOCIAL_MEDIA_READ) | List all registered accounts |
| DELETE | /social-media/accounts/:id |
AdminGuard (SOCIAL_MEDIA_WRITE) | Remove an account |
| GET | /social-media/accounts/:id/authorize-url |
AdminGuard (SOCIAL_MEDIA_WRITE) | Returns {url} — the platform’s OAuth authorize URL, state-encoded to this account/tenant |
| GET | /v1/integrations/social/:platform/oauth/callback |
@Public(), tenant-excluded |
Called by the OAuth provider’s redirect, not the frontend directly — see above |
| GET | /social-media/constraints |
AdminGuard (SOCIAL_MEDIA_READ) | The (platform, placement) constraints table as JSON — for the composer’s live per-target counters |
| GET | /social-media/platform-enabled |
AdminGuard (SOCIAL_MEDIA_READ) | {enabled: boolean} — the platform-wide composer readiness gate; see below and “Platform Settings” |
| GET | /social-media/available-platforms |
AdminGuard (SOCIAL_MEDIA_READ) | {platforms: SocialPlatform[]} — which platforms the “Add Account” picker should offer; see below |
| POST | /social-media/posts |
AdminGuard (SOCIAL_MEDIA_WRITE) | {content, targets: {accountId, placement, contentOverride?}[]} — creates a DRAFT with one PENDING target per (account, placement) pair |
| GET | /social-media/posts |
AdminGuard (SOCIAL_MEDIA_READ) | Paginated (?page=&limit=) |
| GET | /social-media/posts/:id |
AdminGuard (SOCIAL_MEDIA_READ) | One post with its targets, each target’s account, and its media |
| POST | /social-media/posts/:id/media |
AdminGuard (SOCIAL_MEDIA_WRITE) | Multipart, field files (up to 10, 200MB cap, image/video only) — DRAFT posts only |
| DELETE | /social-media/posts/:id/media/:mediaId |
AdminGuard (SOCIAL_MEDIA_WRITE) | DRAFT posts only |
| PATCH | /social-media/posts/:id/targets/:targetId/override |
AdminGuard (SOCIAL_MEDIA_WRITE) | {contentOverride: string | null} — DRAFT posts only, null clears it |
| PATCH | /social-media/posts/:id/targets/:targetId/focal-point |
AdminGuard (SOCIAL_MEDIA_WRITE) | {x, y: number | null}, 0-1 — DRAFT posts only, must be set/cleared together |
| POST | /social-media/posts/:id/publish |
AdminGuard (SOCIAL_MEDIA_WRITE) | Attempts every target; see publish semantics above |
| GET | /social-media/posts/:id/targets/:targetId/stats |
AdminGuard (SOCIAL_MEDIA_READ) | {viewCount?, likeCount?, commentCount?} — YouTube only today; 400 if the target hasn’t published yet |
| POST | /social-media/posts/:id/schedule |
AdminGuard (SOCIAL_MEDIA_WRITE) | {scheduledFor: ISO string} — DRAFT only, must be in the future |
| POST | /social-media/posts/:id/schedule/cancel |
AdminGuard (SOCIAL_MEDIA_WRITE) | Reverts to DRAFT, removes the queued job |
| DELETE | /social-media/posts/:id |
AdminGuard (SOCIAL_MEDIA_WRITE) | DRAFT/FAILED only |
social_media is a toggleable module (KNOWN_MODULES, ModuleEnabledGuard). New social_media:read/
social_media:write permissions, backfilled onto existing SuperAdmin roles by
GrantSocialMediaPermissions1791504000000 (same class of fix as GrantFormsPermissions — a brand-new permission
is only auto-granted to a SuperAdmin role at the moment that role is created).
GET /social-media/platform-enabled — a guard-access ping, not a standalone switch. Originally paired with a
global PlatformSettingKey.SOCIAL_MEDIA_ENABLED kill switch (an all-tenants-at-once readiness gate, separate from
a tenant’s own module toggle) — retired once Tenant.moduleOverrides (see “Per-tenant manual override” above)
shipped, since plan-features-exclusion plus a per-tenant override achieves the same “not ready for everyone yet”
rollout control with actual per-church granularity, and with real backend enforcement (the old global switch only
ever gated this one frontend check, never the API itself — a technically-inclined tenant could always reach the
real endpoints regardless of what it was set to). The route stays: ModuleEnabledGuard above it already 403s
before the handler runs if the tenant’s own toggle is off, their plan doesn’t include social_media, and no
override grants it — so simply reaching the handler at all already proves access, and it unconditionally returns
{enabled: true}. discuva-admin’s /social-media page still fetches this on load and shows “Coming Soon” on
any failure (including the 403 this guard produces), so the frontend behavior is unchanged even though what’s
being checked underneath is now the real per-tenant module/plan/override chain instead of a separate global flag.
Per-platform availability. The module/plan/override chain above is all-or-nothing across every platform at
once — it can’t hide just X/TikTok while shipping Facebook/Instagram/YouTube. GET /social-media/available-platforms fills that gap: it intersects IMPLEMENTED_PLATFORMS
(src/social-media/constant/implemented-platforms.constant.ts — platforms with a real
SocialOAuthExchanger/SocialPlatformPublisher, currently Facebook/Instagram/YouTube; X/TikTok resolve to
NoExchangerAvailable/NotConnectedPublisher regardless of what’s registered for them) with
PlatformSocialAppService.listActivePlatforms() (registered and isActive in social_platform_apps).
discuva-admin’s AccountsPanel filters its “Add Account” platform <select> down to this list — a platform stays
unselectable until it’s both built and deliberately activated from discuva-platform’s existing Deactivate/Reactivate
toggle, with no new admin UI needed for it. This is a frontend picker restriction only — POST /social-media/accounts itself still accepts any SocialPlatform enum value; connecting an account for an
unavailable platform still fails honestly via NoExchangerAvailable either way, same as before this endpoint
existed.
Platform-admin surface (/platform/social-media-apps, discuva-platform): separate from the tenant-side module
toggle above — Discuva staff register each platform’s OAuth app credentials here (one app per platform, not
per-tenant) and flip the kill switch. New PlatformAdminPermission.SOCIAL_MEDIA_APPS_READ/WRITE (a distinct,
disjoint enum from tenant-side AdminPermission.SOCIAL_MEDIA_READ/WRITE — a tenant admin composing/publishing
posts never needs to see Discuva’s own app secrets).
| Method | Route | Auth | Notes |
|---|---|---|---|
| GET | /platform/social-media-apps |
PlatformAdminGuard (SOCIAL_MEDIA_APPS_READ) | Never returns clientSecretEncrypted — select: false |
| GET | /platform/social-media-apps/scope-catalog |
PlatformAdminGuard (no extra permission — metadata, same posture as permissions/groups) |
{[platform]: {scopes: {value,label,required}[], separator}} — drives the register form’s scope picker |
| POST | /platform/social-media-apps |
PlatformAdminGuard (SOCIAL_MEDIA_APPS_WRITE) | {platform, clientId, clientSecret, redirectUri, scopes: string[]} — upserts (one row per platform) |
| PATCH | /platform/social-media-apps/:platform |
PlatformAdminGuard (SOCIAL_MEDIA_APPS_WRITE) | {isActive} — the kill switch; never touches already-connected tenants’ SocialAccount tokens either way |
| DELETE | /platform/social-media-apps/:platform |
PlatformAdminGuard (SOCIAL_MEDIA_APPS_WRITE) | Hard delete — 204, 404 if not registered. Safe: SocialAccount has no FK to this table (just a platform enum string), so nothing orphans; an already-connected tenant’s tokens are untouched, same as deactivating |
Scope validation. scopes used to be a raw free-text string, passed straight into the OAuth scope query
parameter with no validation beyond “not empty” — a typo or wrong separator saved silently and only surfaced later,
either as Meta quietly dropping unrecognized permissions (no error at all, just fewer permissions than intended) or
Google’s consent screen showing invalid_scope. It’s now scopes: string[], one OAuth permission per entry, checked
in PlatformSocialAppService.upsertApp() against KNOWN_SOCIAL_SCOPES
(src/platform-admin/constant/known-social-scopes.constant.ts) — a per-platform whitelist with a required flag per
scope, verified against Meta’s and Google’s own permission docs (Facebook: pages_show_list,
pages_read_engagement, pages_manage_posts; Instagram: those three plus pages_read_user_content,
instagram_basic, instagram_content_publish; YouTube: .../auth/youtube.upload required,
.../auth/youtube.readonly optional — needed only for the stats extension point). upsertApp() rejects (400) any
unrecognized scope and any submission missing a required one, then joins the array with that platform’s own
separator (SCOPE_SEPARATOR — comma for Meta, space for Google, matching each platform’s own OAuth dialog
convention) before storing — the DB column itself is unchanged, still a single string. Platforms with no exchanger
built yet (X, TikTok) have no catalog entry, so any non-empty list is accepted rather than guessed at.
Frontend: RegisterAppPanel replaced the free-text scopes input with a checkbox list fetched from the scope
catalog — required scopes are pre-checked and disabled (can’t be unchecked, since submitting without one always
400s anyway), so the most common mistake is now structurally impossible rather than just validated after the fact.
Platforms with no catalog fall back to a comma-separated free-text input. The apps list table gained a “Scopes”
column rendering each granted scope’s catalog label (falling back to the raw value for anything not in the catalog,
e.g. a scope later removed from it) — previously scopes weren’t visible anywhere in the UI at all.
Facebook Login for Business vs. classic scope-based login
(SocialPlatformApp.configId). Discovered live against a real Meta App: the “Manage everything on your Page”
use case (Facebook Login for Business) does not grant permissions via the classic scope query parameter at all —
it requires a Configuration ID, created in the Meta App dashboard (Facebook Login for Business product >
Configurations), where the actual permission list lives. Sending scope alongside/instead of config_id on a
Business Login app produces a partial, confusing failure — some permissions silently rejected as “Invalid Scopes”
(Meta’s own error, shown only to developers) rather than a clean success or a clean rejection of the whole request.
configId is a new nullable column on SocialPlatformApp; MetaGraphApiService.buildAuthorizeUrl() sends
config_id instead of scope whenever it’s set, and only falls back to the classic scope param when it’s null —
the two are mutually exclusive on the dialog, never sent together. scopes is still recorded and validated even
when configId is set — useful as a record of what the Configuration is expected to grant, even though it isn’t
what’s literally sent in that case.
Frontend: RegisterAppPanel shows a “Configuration ID” field for FACEBOOK/INSTAGRAM specifically, with guidance
on where to find it in the Meta dashboard; the Scopes field’s label changes to clarify it’s reference-only once a
Configuration ID is set. The apps list table shows a “Business Login (config_id)” badge in place of the scope pills
for any row that has one, so which mode a registered app is in is visible at a glance instead of requiring a DB
query to diagnose the next time this exact class of error shows up.
Bug found and fixed while wiring this up: PlatformSocialAppService.getDecryptedApp() — the one method that
reads SocialPlatformApp for actual OAuth use — passes an explicit column select array (needed to opt back into
clientSecretEncrypted’s select: false), and configId was never added to it. TypeORM silently omits any column
not in an explicit select list, so app.configId came back undefined on the object buildAuthorizeUrl() actually
receives at connect time — meaning the fix above would have compiled, passed its own unit tests (which mock the
repository directly, bypassing this), and still silently done nothing in production. Added configId to the select
list and a regression test asserting it’s present, specifically because this class of bug — correct in isolation,
broken through one specific read path — doesn’t show up any other way.
Edit and delete for a registered app. Neither existed before this — the only way to “edit” was re-registering
blind for the same platform (a full overwrite via the same upsert, requiring every field retyped including a
Client Secret that’s never returned by any GET), and there was no way to remove a platform app at all, only
deactivate it. DELETE /platform/social-media-apps/:platform is a genuine hard delete (see routes table above) —
safe specifically because SocialAccount has no FK to SocialPlatformApp, only a plain platform enum string, so
nothing relational can orphan; an already-connected tenant’s stored tokens are untouched either way, identical to
deactivating. RegisterAppPanel now accepts an editingApp prop: platform is locked (can’t retarget an edit to a
different platform — delete and re-register instead), Client ID/Redirect URI/Configuration ID pre-fill from the
existing row, and stored scopes are parsed back into the checkbox picker using that platform’s catalog separator.
Client Secret still can’t be pre-filled (never returned by the API) and must be re-entered to save any change,
same constraint the upsert endpoint always had. The apps list table gained Edit (pencil) and Delete (trash, with a
native confirm() warning what deleting affects) actions alongside the existing Deactivate/Reactivate toggle.
Meta Data Deletion Callback (src/social-media/service/meta-data-deletion.service.ts,
controller/meta-data-deletion.controller.ts). Meta’s Platform Terms §3(d)(i) require every app to either
periodically process a manual list of app-scoped user IDs to purge, or implement a Data Deletion Callback URL that
automates it — registered in the Meta App dashboard’s Advanced settings as the “Data Deletion Request URL”. Meta
POSTs application/x-www-form-urlencoded with a single signed_request field
({base64url_sig}.{base64url_json_payload}, HMAC-SHA256 signed with the app’s client secret) whenever a user
deauthorizes the app or requests deletion from their Facebook Account Settings.
MetaDataDeletionService.verifySignedRequest() tries the signature against every registered Meta-platform app’s
secret (Facebook, then Instagram — both typically share one Meta App, tried independently in case they were ever
registered with different credentials), using timingSafeEqual on equal-length buffers, and rejects (throws
BadRequestException, never silently no-ops) anything malformed, unsigned, or signed with an unrecognized secret —
a forged POST to this public URL must never appear to succeed. PlatformSocialAppService.getDecryptedApp() supplies
the plaintext secret (same decrypt-on-demand pattern the OAuth exchange flow already uses).
The actual “deletion” is close to a no-op by design: SocialAccount.externalAccountId stores the connected Facebook
Page’s id, never the authorizing person’s own Facebook-scoped user id, and connectedBy is our own internal
Admin FK, not a Meta identifier — so there is structurally nothing in Discuva’s database keyed to the user_id
Meta’s signed_request identifies. recordRequest() just persists a SocialDataDeletionRequest row (public schema,
no tenant context — same reasoning as SocialOAuthCallbackController) so the status URL Meta’s response contract
requires isn’t a dead link, and returns a confirmation code immediately; no async job needed. GET .../data-deletion/status/:code renders a small human-readable HTML page (Meta’s contract explicitly requires a
person be able to read it, not just machines) explaining that no personal data was retained.
| Method | Route | Auth | Notes |
|---|---|---|---|
| POST | /integrations/social/meta/data-deletion |
@Public(), tenant-excluded |
Meta calls this directly; body is form-encoded signed_request, not JSON. Responds {url, confirmation_code} |
| GET | /integrations/social/meta/data-deletion/status/:code |
@Public(), tenant-excluded |
Human-readable HTML status page — the URL returned above |
META_DATA_DELETION_STATUS_BASE_URL (optional env var, matching YOUTUBE_WEBSUB_CALLBACK_URL’s own
Joi.string().uri().allow('').optional() convention) supplies the public base URL used to build the status link;
falls back to the incoming request’s own protocol/host when unset, which is fine for local testing but not behind a
proxy that rewrites those.
Utility Module
Shared infrastructure used across the entire application.
Idle-queue Redis load (BullModule.forRootAsync in app.module.ts): Bull runs three independent per-queue background timers (drainDelay, guardInterval, stalledInterval — confirmed unrelated to each other in bull/lib/queue.js: drainDelay is a BRPOPLPUSH blocking-timeout argument, guardInterval drives a self-rescheduling delayed-job setTimeout chain, stalledInterval is a plain setInterval) that hit Redis on a fixed schedule the moment a queue’s .process() handler is registered — completely independent of whether any job is ever enqueued. Left at Bull’s defaults (5s/5s/30s) across this app’s 7 queues, that’s on the order of 250k+ idle Redis commands/day, which is what exhausted the production Upstash request quota with zero tenants live (2026-08-12) — not tenant traffic. Fixed via the shared root settings: drainDelay/guardInterval pushed to their practical max (3600s / 3600000ms) since both have zero real latency cost at any value — Redis’s blocking BRPOPLPUSH wakes immediately the instant a real job is pushed regardless of the polling ceiling, and guardInterval’s ceiling self-adjusts down whenever a delayed job is actually scheduled (none exist anywhere in this codebase — no call site uses Bull’s delay/repeat job options). stalledInterval is the one setting with a genuine trade-off (how long a crashed worker’s job sits unclaimed before Bull reclaims and retries it), kept at 600000ms (10 min) — a non-issue given the single always-on machine and short-lived handlers this app uses. The follow-up queue defines its own settings object (needed for its longer lockDuration), which fully replaces rather than merges with the root config, so all three values are repeated there explicitly (its stalledInterval was previously a bespoke 60000ms with no documented reason for the faster recovery — aligned to the same 600000ms as every other queue).
Bull Board (queue dashboard): Mounted at GET /queues on the NestJS HTTP server. Provides a standalone web UI showing all six queues (email, push-notifications, follow-up, tithe, finance-reconciliation, audit-log) with pending/active/completed/failed job counts and per-job retry controls. Protected by HTTP Basic Auth (BULL_BOARD_USER / BULL_BOARD_PASSWORD env vars). If either env var is absent the dashboard is not mounted. Registered before Helmet so the /queues path is exempt from the strict Content Security Policy.
Email queue (EmailQueueService + EmailProcessor): All outbound email goes through a Bull queue backed by Redis. EmailQueueService.queueEmailWithTemplate() compiles the HTML template using Handlebars and adds a job to the email queue. The platform-wide default email provider is resolved at startup from EMAIL_PROVIDER and injected via EMAIL_PROVIDER_TOKEN; per-send, EmailProcessor may instead resolve a tenant’s own BYOK provider — see “Email BYOK send path” under Communication Providers above. Five providers are available — see the table under Communication Providers above — all accepting optional per-call BYOK credentials. Bull handles retries automatically — 5 attempts, 5-second fixed backoff. On success or permanent failure, a row is written to email_logs with the provider field set to whichever provider actually processed that specific job. Writing that log row is wrapped in its own try/catch in both onCompleted and onFailed — a failure there (e.g. a missing column from an unapplied migration) is logged and swallowed rather than propagating, since these are Bull event handlers with no request context to catch an unhandled rejection; letting one crash the process over an audit-trail write is worse than losing that one log row.
Per-tenant branding (church_name/church_address/logo_url/support_email): resolved fresh per email by EmailQueueService.resolveBrandingData(), not read once at boot — the HTML is fully rendered before the job is queued, so this happens at enqueue time via cls.get('tenantId') → Tenant row lookup, cached under tenant-branding:${tenantId} (CACHE_TTL_REFERENCE_SECONDS, same TTL/pattern as PlanGuard’s plan-features:${tenantId} cache). Any write to a tenant’s name/logo/tagline/address (PATCH /tenant/info, POST/DELETE /tenant/logo, platform-admin’s PATCH /platform/tenants/:id) must invalidate this same key or emails keep stale branding for up to the TTL — all four call sites already do. A tenant field left unset (null) falls back to that one field’s CHURCH_NAME/CHURCH_ADDRESS/LOGO_URL env default, not the whole record — except support_email, which has no env fallback at all (empty string when unset) since a wrong/generic contact address would be actively misleading, not just generic; the 43 member/worker-facing templates that reference it gate the line behind {{#if support_email}} so it’s simply omitted rather than showing nothing useful. product_name always comes from PRODUCT_NAME (the SaaS product name) regardless of tenant — it’s platform-wide, not per-church. Bug fixed (2026-08-04): five templates (tithe-proof-{confirmed,declined,submitted}, pledge-contribution-{confirmed,declined}) had a hardcoded external logo URL instead of {{logo_url}} — every tenant’s emails from those five showed the same wrong logo, not their own. annual-giving-statement.html had no logo image at all. Both fixed; all 61 templates now reference {{logo_url}}. Formerly a known gap, now fixed: every @Cron scheduler in the codebase that touches tenant-scoped data (FollowUpScheduler included) now wraps its per-run body in forEachActiveTenant() (src/tenant/utility/for-each-active-tenant.ts), which fetches every active Tenant and re-enters that tenant’s CLS/SET LOCAL search_path context (via the existing runInTenantContext() helper) once per tenant before running the body — so branding, currency, and every other tenant-scoped lookup inside a scheduled job now resolves correctly per tenant instead of falling back to env defaults or reading the wrong schema. One tenant’s failure is caught and logged per-tenant so it doesn’t stop the rest of the batch. Only YoutubeSubscriptionScheduler (genuinely control-plane data) and SubscriptionLapseScheduler’s top-level query (also control-plane) are exempt.
Tenant-aware login URLs (login_url/admin_login_url, added 2026-08-04, admin_login_url mechanism changed 2026-08 — Phase 9l): LOGIN_URL/ADMIN_LOGIN_URL are configured as bare base URLs — every caller used to read them straight from ConfigService and pass the bare value into a template, which meant every tenant’s login links pointed at the same non-tenant-scoped host. resolveBrandingData() auto-injects both into every email, but the two now use different rewriting mechanisms, not the same one:
login_url(discuva-member — a real per-tenant wildcard):buildTenantUrl()(src/tenant/utility/tenant-url.ts) inserts the subdomain as the leftmost host label —https://discuva.org/login→https://church-alpha.discuva.org/login.admin_login_url(discuva-admin — a single fixed host, no wildcard):buildAdminUrl()(same file) instead adds the subdomain as a?subdomain=query param, since there’s no per-tenant host to prepend it onto anymore —https://admin.discuva.org/login→https://admin.discuva.org/login?subdomain=church-alpha. discuva-admin’s login/set-password forms read this param to pre-fill their “Church Subdomain” field.
All ~17 call sites that used to compute these themselves (AuthService, AdminService, MemberService, MemberImportService, IncidentReportService, and four schedulers) were simplified to rely on the auto-injected value instead — a call site should never read LOGIN_URL/ADMIN_LOGIN_URL from ConfigService directly. The one exception is AuthService.sendSessionSecurityAlert(), which has to pick between the two based on which surface (member/admin) a session belongs to — it calls UtilityService.resolveTenantLoginUrl('member' | 'admin') (delegates to EmailQueueService.resolveTenantUrl(), which internally branches to buildTenantUrl/buildAdminUrl the same way resolveBrandingData() does) directly instead. TenantProvisioningService.sendWelcomeEmail()'s set-password link is a separate case again — it builds its URL from the Tenant object already in hand (buildAdminUrl(ADMIN_LOGIN_URL, '/set-password', { email, otp, subdomain: tenant.subdomain })) rather than through CLS, since it can run from a Bull job or a synchronous platform-admin call with no tenant CLS context active; the path argument replaces ADMIN_LOGIN_URL’s own path rather than appending onto it, which also incidentally fixed a pre-existing /login/set-password double-path bug the old string-concatenation version had. PLATFORM_LOGIN_URL is deliberately untouched — platform admins aren’t tied to any tenant.
Tenant-aware email subject lines (EmailQueueService.resolveChurchName()/UtilityService.resolveChurchName(), added 2026-09-05): Same class of bug as the login-URL one above, just for the SUBJECT line rather than the body. A template body already gets the correct per-tenant {{ church_name }} automatically via resolveBrandingData(), but a subject is a plain string built in TypeScript before any template is touched — six services (MemberService, RequestLeaveService, AttendanceService, AuthService, MemberImportService, ChildrenChurchService) instead cached PRODUCT_NAME/CHURCH_NAME once in their own constructor from ConfigService and interpolated that into the subject directly. Two symptoms: (1) most of these used PRODUCT_NAME (e.g. a worker’s promotion email read “Welcome to Discuva Workforce” instead of naming their own church), and (2) even the ones already using CHURCH_NAME showed the same single env-configured value for every tenant on the platform, not the recipient’s actual church. EmailQueueService.resolveChurchName() (public — resolves the current tenant via the same CLS + tenant-branding cache getCurrentTenant() already uses, falling back to env CHURCH_NAME only when there’s no tenant context) and its UtilityService delegate replace all ~20 affected subject-line interpolations across those six files; each caller does const churchName = await this.utilityService.resolveChurchName(); once before building the subject (once per request/batch, not once per recipient in a loop). Every now-dead private readonly productName/ConfigService constructor plumbing that had no other use in its file was removed alongside it (RequestLeaveService and ChildrenChurchService no longer inject ConfigService at all).
Domain map (discuva.org)
| Host | App | Notes |
|---|---|---|
discuva.org, www.discuva.org |
Homepage/marketing + self-serve signup | Bare root — extractSubdomain returns null, so tenant-aware URL helpers fall back to the bare base URL here too. |
platform.discuva.org |
discuva-platform | platform is in RESERVED_SUBDOMAINS on both frontend and backend — no tenant can ever claim it. No tenant logic in this app at all (confirmed: no middleware.ts, no Host-header parsing anywhere) — every route it calls is /v1/platform/*, already excluded from TenantMiddleware. |
{tenant}.discuva.org |
discuva-member | Real per-tenant wildcard for the app’s own hosting — extractSubdomain resolves it the normal way, unchanged. discuva-member’s outgoing API calls no longer need to share this host: they target api.discuva.org directly, carrying the subdomain as an X-Tenant-Subdomain header (pre-auth) or a JWT tenant claim (authenticated) instead of via the URL’s own hostname (Phase 9m). No router/path-split needed in front of this host anymore. |
admin.discuva.org |
discuva-admin | Fixed, single origin — no wildcard needed. See “Fallback resolution for a fixed, non-wildcard host” above: tenant identity travels in the JWT (post-login) or an explicit X-Tenant-Subdomain header (login only), not the hostname, so this app doesn’t need — and structurally can’t use — a per-tenant subdomain of its own. |
api.discuva.org |
discuva-api | Reachable directly by every app, always — discuva-admin’s and discuva-member’s tenant resolution both work over this fixed host (see “Fallback resolution for a fixed, non-wildcard host” above), plus discuva-platform, discuva-web’s POST /v1/signup, third-party webhook URLs (Paystack/Flutterwave/YouTube dashboards), health/docs. |
Only one DNS zone needs wildcard coverage — *.discuva.org, and only for discuva-member’s own hosting, not for
any traffic bound for the API. admin.discuva.org and api.discuva.org are both plain, single DNS records, and
neither needs a router in front of it splitting by path — a design an earlier draft of this table used to describe,
which existed only to work around discuva-admin and discuva-member both needing to reach the API without a
subdomain of their own to carry a tenant on. LOGIN_URL should be configured against the discuva.org zone;
ADMIN_LOGIN_URL against admin.discuva.org (no longer the discuva.org zone — the admin app moved to its own
dedicated host).
Email category gating: queueEmail* methods accept an optional category?: EmailCategory argument. If no category is supplied the email always sends (used for security-critical auth emails: OTP, password reset, account locked, etc.). Optional categories are gated by boolean config flags (EMAIL_*_ENABLED); setting a flag to false suppresses that category without touching any call sites. Current categories:
| Category | Flag | Default |
|---|---|---|
ATTENDANCE_CHECKIN |
EMAIL_ATTENDANCE_CHECKIN_ENABLED |
true |
BIRTHDAY |
EMAIL_BIRTHDAY_ENABLED |
true |
EVENT_REMINDER |
EMAIL_EVENT_REMINDER_ENABLED |
true |
PRAYER_REMINDER |
EMAIL_PRAYER_REMINDER_ENABLED |
true |
FOLLOW_UP |
EMAIL_FOLLOW_UP_ENABLED |
true |
ASSET_ALERTS |
EMAIL_ASSET_ALERTS_ENABLED |
true |
GIVING_RECEIPT |
EMAIL_GIVING_RECEIPT_ENABLED |
true |
FINANCE_ALERTS |
EMAIL_FINANCE_ALERTS_ENABLED |
true |
SESSION_REPORT |
EMAIL_SESSION_REPORT_ENABLED |
true |
INCIDENT_REPORT |
EMAIL_INCIDENT_REPORT_ENABLED |
true |
CHILDREN_CHURCH |
EMAIL_CHILDREN_CHURCH_ENABLED |
true |
LOGIN_ALERT |
EMAIL_LOGIN_ALERT_ENABLED |
true |
SERVICE_PROGRAMME_ASSIGNMENT |
EMAIL_SERVICE_PROGRAMME_ASSIGNMENT_ENABLED |
true |
PASTOR_FEEDBACK |
EMAIL_PASTOR_FEEDBACK_ENABLED |
true |
ASSIGNMENT_REMINDER |
EMAIL_ASSIGNMENT_REMINDER_ENABLED |
true |
CLASS_SESSION_REMINDER |
EMAIL_CLASS_SESSION_REMINDER_ENABLED |
true |
FORM_SUBMISSION |
EMAIL_FORM_SUBMISSION_ENABLED |
true |
SUNDAY_SCHOOL_QA |
EMAIL_SUNDAY_SCHOOL_QA_ENABLED |
true |
SUNDAY_SCHOOL_ATTENDANCE |
EMAIL_SUNDAY_SCHOOL_ATTENDANCE_ENABLED |
true (push-only: check-in open, weekly absentees) |
TRAINING_CLASSES |
EMAIL_TRAINING_CLASSES_ENABLED |
true (push-only: join request approved/declined, certificate ready) |
EVANGELISM |
EMAIL_EVANGELISM_ENABLED |
true (push-only: added to an outreach team, convert(s) assigned) |
NOTES |
EMAIL_NOTES_ENABLED |
true (push-only: evening after a service, Monday weekly step; members can also opt out) |
DEPARTMENT_GOAL_ACTIVITY |
EMAIL_DEPARTMENT_GOAL_ACTIVITY_ENABLED |
true (push-only for now — see Department Goals) |
Template files live in src/utility/templates/*.html and use {{variable}} for simple substitution, {{#if}} for
conditionals, and {{#each}} for loops. Values are HTML-escaped automatically; use {{{variable}}} only for
intentional raw HTML.
Calendar invites (.ics) — buildIcsEvent, src/utility/util/ics-builder.ts: a small, dependency-free
builder (BEGIN:VCALENDAR/VEVENT text assembly, no npm ics package) shared by every feature that emails someone
about a specific dated event. Takes { uid, startTime, endTime, summary, description, location? } and returns a
Buffer — pass it to UtilityService.sendEmailWithAttachment(to, subject, templateName, templateData, [{ filename, content }], category?)
(→ EmailQueueService.queueEmailWithTemplateAndAttachments) alongside the usual templated email. The builder
doesn’t own event identity — uid is the caller’s full string (e.g. `${slotId}@service-programme`), so a
recipient’s calendar app can tell “this is an update to an event I already have” from “this is a new event” based
entirely on whether the caller reuses the same uid across sends. Two consumers today:
- Service Programme (
ServiceProgrammeService.notifySlotAssignment,ServiceProgrammeReminderScheduler) —uid:`${slotId}@service-programme```, attached whenever the underlyingServiceSlothas both astartTime/endTime; skipped otherwise (no time range to build an event from). - Classes (
ClassSessionReminderScheduler) — see the Classes Module reminder-scheduler notes below;ChurchClasshas no explicit session-duration field, so this consumer defaultsendTimetostartTime + 1hrather than omitting the invite.
Cloudinary (CloudinaryService): Streams file uploads to Cloudinary via upload_stream with resource_type: 'auto'. Used for finance request attachments, payment proofs, and tithe payment proofs. uploadBuffer(buffer, folder, filename?) returns {secureUrl, publicId, resourceType} — callers must persist publicId and resourceType so that assets can be deleted without re-parsing the URL. deleteByPublicId(publicId, resourceType) destroys the asset using the stored values (replaces the old deleteByUrl which hardcoded resource_type: 'raw'). The service validates all three credentials on module init and throws if any are missing. Credentials are read from CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, and CLOUDINARY_API_SECRET.
Cache (CacheService): A Redis-backed key-value cache. All read operations (get) are awaited — the result is
needed before the request can continue. Write operations (set, del) are fire-and-forget for non-critical
data (cache population after a DB fetch, cache invalidation on mutations) — if the Redis write is lost, the worst
case is a cache miss on the next request, which falls through to the database. Rate-limit reads are always awaited;
rate-limit counter clears and increments are fire-and-forget.
Caching strategy by data type:
| Data | Key pattern | TTL | Invalidation |
|---|---|---|---|
| Department list | departments:all |
CACHE_TTL_REFERENCE_SECONDS |
On any CRUD |
| Venue list | venues:all |
CACHE_TTL_REFERENCE_SECONDS |
On any CRUD |
| Event config list | event-config:all |
CACHE_TTL_REFERENCE_SECONDS |
On any CRUD |
| Leaderboard | leaderboard:{days}:{limit} |
CACHE_TTL_LEADERBOARD_SECONDS |
TTL only |
| Rate limit keys | login_fail:{email} etc. |
Per-window duration | On success |
Birthday Module
Automatically greets members on their birthday with an email and a congregation-wide announcement. Other members can send personal wishes that persist permanently in the member’s birthday book.
Cron: Runs daily at 6 AM. Queries all active members whose birthMonth and birthDay match today and whose birthdayGreetedYear is not the current year, then for each member (in an isolated try/catch):
- Creates an
ALL-audience announcement withexpiresAt = 23:59:59tonight - Updates
birthdayGreetedYearto the current year (only after the announcement saves) - Sends the birthday email (fire-and-forget via email queue)
Resilience: BirthdayService implements OnApplicationBootstrap. On startup, if the hour is ≥ 6, it fires triggerBirthdayGreetings() as a background task (fire-and-forget, guarded by a separate lock:birthday-catchup Redis lock). This recovers greetings missed because the app was down at 6 AM — the birthdayGreetedYear field prevents re-sending to members already greeted. Per-member isolation means one member’s failure never blocks the rest.
birthdayGreetedYear: Integer column (smallint) on the Member entity. Null for members who have never been greeted. Set to the current year after a successful greeting. The cron and catch-up both filter WHERE birthdayGreetedYear IS NULL OR birthdayGreetedYear != currentYear to skip already-greeted members.
Wish wall: Wishes persist in birthday_wishes regardless of announcement expiry. Rate-limited to WISH_DAILY_LIMIT
wishes per sender per day (default: 20). Input is DOMPurify-sanitized.
Fields returned by /birthday/upcoming (admin-only): id, firstname, lastname, email, phoneNumber, birthMonth, birthDay, birthYear. birthYear is nullable — members aren’t required to disclose it, so the endpoint only ever uses birthMonth/birthDay (recurring, year-independent) to determine “is today/upcoming a birthday”; birthYear is included purely so callers can render a full date when it’s known, falling back to a day+month-only display when it’s not.
Fields returned by /birthday/today (member-facing, BirthdayCelebrant): id, firstname, lastname, birthMonth, birthDay, birthYear, role, departmentName, clergyTitleName, alreadyWishedByMe, photoUrl. Deliberately does not include email/phoneNumber — those are fine for the admin-only /birthday/upcoming view but not for a response every member can call. Same-named celebrants are disambiguated instead via role/departmentName (from workerProfile.department, loaded via the workerProfile and workerProfile.department relations), clergyTitleName (from the clergy.title relation — a flat string, unlike MemberDto’s nested clergy, since this is pure display and never drives a form), and now photoUrl (from Member.photoUrl — see Member Module) — the mobile UI shows the photo when set, falling back to initials.
alreadyWishedByMe on /birthday/today: computed per request from the caller’s own JWT identity (not present on /birthday/upcoming, which is admin-only and has no “sender” concept) — true when the calling member already has a BirthdayWish row for that recipient this calendar year. sendWish() already enforced one-wish-per-sender-per-recipient-per-year at the DB level (@Unique(['recipient', 'sender', 'year']) on BirthdayWish) and rejected a second attempt with 400 — this field just surfaces that same state proactively on load, computed via a single extra query (BirthdayWish.find({ sender, year, recipient: In(todaysBirthdayIds) })) rather than the client only discovering it reactively after a failed second send.
Routes prefix: /birthday
Membership Anniversary Module
Background-only (no controller/routes) — automated congratulatory announcement + email when a member reaches a
join-date anniversary, keyed off Member.dateJoinedChurch rather than createdAt (a member’s system-account
creation date can differ from their actual join date, e.g. for admin-backfilled historical records).
Structurally a clone of the Birthday Module’s mechanics rather than a new pattern: same lock-key/catch-up/daily-cron
shape (@Cron('0 6 * * *'), onApplicationBootstrap catch-up for missed runs, Redis lock to prevent double-sends
across instances), same memberGreetedYear-style dedup marker (Member.anniversaryGreetedYear, mirrors
birthdayGreetedYear). Differs from Birthday in one way: greeting delivery goes through
AnnouncementService.createSystemAnnouncement() (ALL-audience, push-notifies automatically) rather than Birthday’s
older direct-repository-insert pattern, which predates that helper and doesn’t push-notify.
Eligibility: ACTIVE members with a non-null dateJoinedChurch whose join month/day matches today, excluding
the join year itself (a member who joined today this year has “0 years,” not an anniversary), and not already
greeted this calendar year.
Email: membership-anniversary template, EmailCategory.MEMBERSHIP_ANNIVERSARY (togglable via
EMAIL_MEMBERSHIP_ANNIVERSARY_ENABLED, default true, same convention as every other email category).
Audit: MEMBERSHIP_ANNIVERSARY_GREETED per member greeted (metadata: { years }).
Dashboard Module
Aggregated data endpoints per role. Does not store data — assembles from other services.
Routes prefix: /dashboard
Sunday School Module
Manages permanent Sunday School classes, class membership, and session-based attendance. Classes have no graduation — members stay assigned indefinitely. Both teachers and enrolled students can mark attendance, but self-mark requires that a staff member has opened the window on the session.
Key flows:
- Admin or SS-dept worker creates a class and assigns a teacher (optional).
- Members are assigned to a class via the members sub-resource. Assignments are permanent until explicitly removed.
- A session is created per class per date. Staff open a timed self-mark window via
PATCH /sessions/:id/open(body:{ closesInMinutes: 5–480 }); members may self-mark only whileselfMarkClosesAtis non-null and in the future. Staff can close the window early viaPATCH /sessions/:id/close. No cron job required — the window expires automatically at query time. - Bulk marking is used by teachers/staff; self-mark (
POST /sunday-school/sessions/:id/checkin) is used by individual members.
Open sessions for member (GET /sunday-school/sessions/open): each returned session carries a computed
alreadyCheckedIn: boolean — true if the calling member already has a PRESENT attendance record for that session.
A session stays “open” for the whole class regardless of whether this particular member has self-marked yet, so the
flag is what lets the member app grey out the Check In button and show “Checked In” instead of leaving it looking
actionable (and re-throwing BadRequestException on a second tap) after a refetch. Re-marking is still allowed when
the existing record is ABSENT/EXCUSED (a teacher’s pre-mark being self-corrected) — only an existing PRESENT
record sets the flag.
Computed fields on class/session responses (added 2026-09-05, audit fix): SundaySchoolClass isn’t stored with a
member count, and SundaySchoolSession isn’t stored with an open/closed status — both are computed at read time
rather than persisted, so they can’t drift from the actual assignment rows / selfMarkClosesAt value:
membersCount— attached to every class returned byGET /sunday-school/classes,GET /admin/sunday-school/classes, and class create/update, via a single groupedCOUNT(*)query across all requested classes (not N+1). A brand-new class always reports0without a query.selfMarkOpen— attached to every session returned by the sessions-list and single-session routes (both worker and admin controllers), computed the same waygetSessionRosteralready computedselfMarkOpeninternally:!!selfMarkClosesAt && now < selfMarkClosesAt.discuva-admin’s sessions table previously tracked open/closed via a client-onlystatusfield that the API never actually returned, which only reflected reality immediately after that same browser tab called Open/Close — a fresh page load showed every session as closed regardless of its real state. The frontend now readsselfMarkOpendirectly off the API response.
markedAt refreshes on re-mark (fixed 2026-09-05, audit fix): SundaySchoolAttendance.markedAt used to only be
set once, at INSERT — re-marking an existing record (a member self-correcting a teacher’s earlier ABSENT mark via
selfMarkPresent, or a teacher overwriting a status via bulkMarkAttendance/adminBulkMarkAttendance) updated
status/markedByTeacher but left markedAt frozen at the original mark time. Since getMyAttendanceHistory sorts
by markedAt DESC, a corrected record could display a stale timestamp and sort out of chronological order. All three
re-mark paths now set markedAt = new Date() when overwriting an existing record.
Class create/update now validates teacherId (fixed 2026-09-05, audit fix): createClass/updateClass and their
admin equivalents previously set the teacher relation from a raw teacherId with no existence check (unlike
assignMember, which already validated the member exists) — an invalid id surfaced only as a raw FK-constraint 500.
Both now throw a clean NotFoundException('Teacher not found') up front.
Session-creation race hardened (fixed 2026-09-05, audit fix): createSession/adminCreateSession check-then-insert
against the DB’s (class, sessionDate) unique constraint; a genuine race between two concurrent creates for the same
class+date now surfaces the same friendly ConflictException message instead of a raw Postgres 23505 error.
First-timer check-in (POST /sunday-school/sessions/:id/checkin-first-timer; admin twin POST /admin/sunday-school/sessions/:id/checkin-first-timer): lets a teacher (unless the church turned teachersCanCheckInFirstTimers off) or an admin
check in someone with no Member record at all — a visiting child/family whose first-ever contact with the church is
literally the Sunday School class, not the main service. SundaySchoolAttendance.member is now nullable, with a new
nullable firstTimer FK (follow_up.FirstTimer) alongside it — DB-enforced XOR (CHK_sunday_school_attendances_ member_xor_first_timer): every row has exactly one of the two, never both, never neither. Two independent UNIQUE
constraints, (session, member) and (session, firstTimer), not one composite — Postgres treats NULL as distinct
per row, so a nullable column already tolerates unlimited NULLs without help from the other column.
- Calls
FollowUpService.createFirstTimerFromSundaySchoolCheckIn()— a new method that calls the same privatedoCreateFirstTimer()every other first-timer creation path uses (round-robin assignment to an active Follow-Up worker,FollowUpTaskcreation, fire-and-forget assignment email) but skipsassertWorkerInFollowUpDept’sMANAGE_FOLLOW_UPcapability gate — a Sunday School teacher has no reason to hold that capability, andSundaySchoolService.requireSundaySchoolAuthalready authorizes the caller before this is ever reached.sourceis forced to the newFirstTimerSourceEnum.SUNDAY_SCHOOLvalue regardless of what’s submitted, same not-spoofable-from-input patterncreateFirstTimerFromPublicFormalready uses forONLINE. - Deliberately not nested inside
bulkMarkAttendance’s pattern of a self-openedthis.attendanceRepo.manager. transaction()—doCreateFirstTimerrelies onthis.txHost.tx, the CLS-ambient transaction FollowUpService manages itself, and mixing that with a second independently-opened transaction risks the tenant schema’sSET LOCAL search_pathnot being visible to whichever transaction manager didn’t set it. The FirstTimer is created as its own step, then a singleSundaySchoolAttendancerow (alwaysPRESENT,markedByTeacher: true) is saved separately. - Fixed the same day:
getSessionRoster/adminGetSessionRosterused to build theirMapkeyed ona.member.idunconditionally — a first-timer attendance row (member: null) would have thrown aTypeErrorthe moment one existed. Both now split attendance rows by which ofmember/firstTimeris set, andSessionRostergained a newfirstTimerCheckIns: {attendanceId, firstTimerId, name, markedAt}[]array so a checked-in guest is actually visible in the roster response, not just recorded invisibly in the DB. - No admin-facing equivalent endpoint yet — this is deliberately worker/teacher-only for now (the actor is always “a Sunday School teacher checking someone in during class”).
Lesson material (SundaySchoolSession.documentUrl): optional link to that date’s lesson material (Google Drive,
PDF link, etc.) — validated as a URL (@IsUrl()), settable only at session creation (POST .../sessions), same as
the pre-existing notes field — neither has an update-after-creation route. Set via either the worker/teacher
controller (mobile) or the admin controller; both share CreateSundaySchoolSessionDto. Surfaced as “View Lesson
Material” on the teacher’s roster panel (discuva-member mobile) and via a small link icon in the admin sessions table.
Routes prefix: /sunday-school (worker/member routes) and /admin/sunday-school (admin routes)
Admin controller (/admin/sunday-school): All routes require AdminGuard. Provides the same class and session management as the worker controller but bypasses the requireSundaySchoolAuth check so that admins can manage any class regardless of department or teacher assignment.
Questions (Q&A) (added 2026-09-06): Any member assigned to a class can ask a private question against it
(POST /sunday-school/classes/:id/questions) — visible only to the asking student and to Sunday School staff/the
class’s teacher, never shared with the rest of the class. SundaySchoolQuestion (new entity, sunday_school_questions
table) holds questionText, and answerText/answeredBy/answeredAt (all null until answered). No new
AdminPermission was introduced — the existing SUNDAY_SCHOOL_READ/SUNDAY_SCHOOL_WRITE pair already gates every
other Sunday School sub-resource in this module (classes, sessions, roster) the same way, matching the codebase-wide
convention that a module gets one READ/WRITE pair, not one per sub-resource.
GET /sunday-school/my-classes— the classes the calling member is assigned to (needed because nothing previously told a student which classes they’re even in; used to populate the class picker when asking a question).POST /sunday-school/classes/:id/questions—JwtAuthGuardonly; the service itself verifies the caller is assigned to the class (ForbiddenExceptionif not), the same checkselfMarkPresentalready does.GET /sunday-school/classes/:id/questions— teacher/SS-staff view of every question asked in one class (requireSundaySchoolAuth).GET /sunday-school/questions/me— the calling member’s own questions across all their classes, paginated.PATCH /sunday-school/questions/:id/answer— teacher/SS-staff answers a question (requireSundaySchoolAuth).GET /sunday-school/questions— cross-class view of every question across every class (added 2026-09-06, UX follow-up): the per-class endpoint above requires opening one class at a time, which doesn’t scale to “what’s been asked across my classes” for a team of several teachers. Gated onDepartmentAccessService.assertHasCapabilityalone — deliberately notrequireSundaySchoolAuth’s class-teacher fallback, since a teacher who isn’t in the SS department is scoped to their own class’s Q&A only, not everyone else’s private questions too. Any true SS-dept worker sees every class’s questions and answers here.- Admin mirrors under
/admin/sunday-school:GET questions(cross-class, no capability check — admin already bypasses department checks everywhere else in this module),GET classes/:id/questions,PATCH questions/:id/answer(both bypassrequireSundaySchoolAuthlike every other admin method here), plusDELETE questions/:idfor moderation.
Teacher-notification fallback rule: asking a question notifies the class’s assigned teacher (email + push, new
EmailCategory.SUNDAY_SCHOOL_QA, gated by EMAIL_SUNDAY_SCHOOL_QA_ENABLED + the per-tenant category toggle same as
every other category). If the class has no assigned teacher, it instead notifies every member with the
MANAGE_SUNDAY_SCHOOL department capability (push only, no email — a fallback for an unusual state, not worth an
inbox hit for the whole team on every question). This reverse lookup — “who has capability X,” as opposed to
DepartmentAccessService.hasCapability’s existing “does this one member have it” — is a new, reusable
DepartmentAccessService.findMemberIdsWithCapability() method, centralizing a raw capability-join query pattern that
previously existed inline in three other services (FollowUpService, ServiceSessionService). Answering a question
notifies the asking member back the same way (email + push). Both legs go through
NotificationDispatchService.notifyMember() — the shared category-gated dispatcher — not the older, ungated
PushNotificationService.dispatchToMemberIds/sendEmailWithTemplate pair some pre-existing modules (e.g.
PastorFeedbackService) still call directly.
Class details & assistants (added 2026-10-01): create/update (admin or worker) accept ageGroup, meetingDay,
meetingTime, location (send null to clear) and assistantIds: uuid[] (replaces the list; the teacher is dropped
from it if included; unknown ids → 404). requireSundaySchoolAuth’s class-teacher fallback now also passes for an
assistant. GET /sunday-school/my-teaching lists the classes a worker teaches or assists (with membersCount), so the
member app can show a Teach view to teachers outside the Sunday School department.
Editing and recurring sessions (added 2026-10-01): PATCH .../sessions/:id changes sessionDate, notes,
documentUrl (empty string/null clears) and keeps the session’s attendance; moving onto a date the class already has
→ 409. POST .../sessions/series ({ classId, startDate, endDate, everyWeeks?: 1–4, notes? }) creates a session every
N weeks from start to end inclusive (UTC date stepping, so DST never shifts a day), skipping dates that already have a
session; at most 60 per call. Returns { created, skipped: date[], dates: date[] }.
Reports (SundaySchoolReportService, added 2026-10-01): GET /admin/sunday-school/reports/attendance?from&to&classId
— default range is the last 12 weeks; to is capped at today (church timezone), so future sessions never count.
Returns { from, to, summary, byClass, bySession, members? }:
bySession:enrolled(current members who had joined by that date),present/absent/excused,unmarked(enrolled − marked),firstTimers,rate.byClass:enrolled(current),sessions,averagePresent,firstTimers,rate.members(only withclassId): per current member —sessionsHeld(since they joined), counts,rate,lastPresent.rateeverywhere is present ÷ (expected − excused) as a percentage with one decimal, ornullwhen nobody was expected. Members who have since been removed from a class aren’t counted as expected (attendance is measured against current membership).GET .../reports/attendance/exportdownloads the same range as an.xlsxwith Classes, Sessions and Members sheets (members across every class in range).
Absentees (“missing lately”): a member is listed when their most recent N sessions in a row (only sessions since they
joined, up to the last 10, none in the future) were missed — ABSENT or never marked; EXCUSED counts as attended.
Inactive members are skipped. misses defaults to 3 (2–10). Admin: GET /admin/sunday-school/reports/absentees?classId&misses;
teacher/assistant: GET /sunday-school/classes/:id/absentees?misses. Rows: { classId, className, memberId, firstname, lastname, email, phoneNumber, missedInARow, lastAttended }, longest streak first.
Indexes for these queries: reports and absentees use the existing sunday_school_sessions(session_date),
(sunday_school_class_id, session_date) unique, sunday_school_attendances(session_id) and
sunday_school_members(sunday_school_class_id) indexes; assistants have a composite PK plus member_id index. The
candidates search ILIKEs firstname/lastname/email word by word so each branch uses the members trigram indexes, and
bulk add by email uses IDX_members_email_lower (LOWER(email), migration 1799737200000-AddMembersLowerEmailIndex).
Teacher marking window (added 2026-10-01): teachers (worker routes) can mark attendance, open check-in and check in
first-timers for a session only until sessionDate + teacherMarkingDays (church timezone; 0 = the session day only,
default 2). After that those routes return 403 “Attendance for this session closed on YYYY-MM-DD. Ask an admin…”.
Admin routes are never limited. The worker roster (GET /sunday-school/sessions/:id/roster) adds teacherMarkingOpen
and teacherMarkingClosesOn so the member app shows the session read-only. Members’ own self check-in is unchanged — it
is governed only by the timed selfMarkClosesAt window.
Notifications (EmailCategory.SUNDAY_SCHOOL_ATTENDANCE, push only):
SUNDAY_SCHOOL_CHECKIN_OPEN— when check-in opens (teacher or admin), to class members not yet marked for that session. Idempotency keysunday-school-checkin-open:{sessionId}:{closesAt}, so re-opening sends again. A failed push never blocks opening.SUNDAY_SCHOOL_ABSENTEES—SundaySchoolAbsenteeScheduler, Mondays 08:00 church time, every active tenant with thesunday_schoolmodule on: to each class’s teacher and assistants, with how many members have missed 3+ in a row. One per class per week (sunday-school-absentees:{classId}:{date}).
Tithe Module
Enables the finance team to manage bank accounts, upload Excel tithe payment sheets, and review member proof submissions. Members can view their own records, request PDF statements, and submit proof of offline payments.
Account management: The finance team maintains a list of tithe bank accounts (TitheAccount) — one per physical bank account. Each account has its own currency (ISO 4217), enabling the church to accept NGN, USD, and any other currency simultaneously. Members and workers can browse active accounts at GET /tithes/accounts. Admins manage accounts via:
| Method | Route | Permission | Notes |
|---|---|---|---|
POST |
/admin/tithes/accounts |
FINANCE_WRITE |
Create account. 409 if (accountNumber, bankName) already exists. |
GET |
/admin/tithes/accounts |
FINANCE_READ |
Lists all accounts (active and inactive), ordered by currency ASC, bankName ASC. |
PATCH |
/admin/tithes/accounts/:id |
FINANCE_WRITE |
Update account details. |
GET |
/admin/tithes/accounts/:id/summary |
FINANCE_READ |
Aggregate totals for one account. See below. |
Account summary (GET /admin/tithes/accounts/:id/summary): Accepts optional fromMonth / toMonth (YYYY-MM) query params and returns:
{
"account": { "...": "TitheAccount fields" },
"fromMonth": "2026-01",
"toMonth": "2026-06",
"bulkTotal": 500000,
"bulkCount": 45,
"proofTotal": 75000,
"proofCount": 8,
"grandTotal": 575000
}
bulkTotal/bulkCount aggregate confirmed TitheRecord rows whose batch is linked to this account. proofTotal/proofCount aggregate CONFIRMED TithePaymentProof rows linked directly to this account.
Upload flow:
- Finance admin selects a
TitheAccountand uploads.xlsxviaPOST /admin/tithes/upload(multipart, field namefile; body fieldtitheAccountId). - Service validates that the account exists and is active, validates required columns (
Email,Amount,Payment Date), and returns 400 immediately for invalid input. - A
TitheUploadBatchrecord is created (linked to the account, with parsed rows stored as JSONB for safe requeue) and a Bull job (tithequeue,process-batchjob) is dispatched withattempts: 3, removeOnFail: false. - The processor runs asynchronously inside a database transaction: matches each row to a member by email (case-insensitive), creates
TitheRecordfor matches,TitheUnmatchedRecordfor no-match rows, andTitheDisputeRecordfor rows that duplicate an existing record by(memberId, paymentDate, amount). The transaction ensures idempotent retries — a mid-batch failure rolls back all inserts so the next attempt starts from a clean slate. A matched row’s optionalgivingOptioncolumn is looked up once per batch (case-insensitive name match againstGivingOption) and set on the createdTitheRecord; blank or unmatched values leave itnull(falls back to “General Giving” on display) rather than guessing.
Failed batch requeue: If a batch reaches FAILED status, a finance admin can requeue it via POST /admin/tithes/batches/:id/requeue. The stored rows JSONB field is used to reconstruct the job without re-uploading the file.
Excel template: Three-sheet workbook — Tithe Template (headers only), Instructions, Sample. Served at GET /admin/tithes/template. Columns: email, amount, paymentDate (all required), reference, bankName, givingOption (all optional — givingOption must match an existing GivingOption name exactly, case-insensitive).
Admin records list: GET /admin/tithes/records returns all confirmed tithe records (paginated, FINANCE_READ). Supports the following query params:
| Param | Type | Description |
|---|---|---|
memberId |
UUID | Filter to one specific member |
departmentId |
UUID | Filter to tithes paid by workers in that department |
fromMonth |
YYYY-MM |
Start of payment date range (inclusive) |
toMonth |
YYYY-MM |
End of payment date range (inclusive, last day of month) |
search |
string | Wildcard match on member firstname, lastname, or email |
accountId |
UUID | Filter to records tied to a specific tithe account |
page / limit |
int | Pagination (default 1 / 20) |
GET /admin/tithes/records/download accepts the same filters (no pagination) and returns an .xlsx file with columns: Member Name, Email, Account (bank name), Currency, Amount, Payment Date, Purpose (the record’s givingOption.name, or “General Giving”), Sender Bank, Source, Payment Gateway, Reference.
Member visibility: Members view their own tithes at GET /tithes/me (loads batch.titheAccount and givingOption relations so the frontend can label each row correctly — see “Giving Statement” below) and request a PDF statement emailed to them at POST /tithes/me/statement/send (TitheService.emailGivingStatement). Optional query params fromMonth and toMonth (format YYYY-MM) filter the statement date range; givingOptionId (UUID) filters it to one Giving Option. When an option is selected, only matching TitheRecords are included; confirmed pledge contributions are included only in the unfiltered all-giving statement because they are not associated with a Giving Option. If only one date bound is supplied the other is open-ended. Returns { message, recordCount } (200 OK). The email body states the date range and selected option when applicable, alongside the record count.
Members can request a separate pledge contribution PDF at POST /tithes/me/pledge-statement/send. It contains only CONFIRMED contributions from the caller’s pledges, with campaign names, dates, amounts, and references; pending/declined contributions and regular giving are excluded. Optional fromMonth/toMonth (YYYY-MM, by payment month; 400 if malformed or reversed) and campaignId narrow it, and the email states the period and campaign; with none it covers everything to date. If nothing matches, no email is sent and the response says so. (lastDayOfMonth now builds the month end in UTC — it previously slipped back a day on servers ahead of UTC.)
Giving Statement — not just a “Tithe Statement” (added 2026-09-01): the emailed PDF is called “Giving Statement,” not “Tithe Statement” — TitheRecord already holds every type of online/manual giving (Tithe, Offering, General Giving, a GivingOption like “Building Fund”), and calling the whole document “Tithe Statement” wrongly implied it covered only one type. emailGivingStatement also merges in the member’s CONFIRMED PledgeContributions for the same date range — pledge-designated gifts live in a separate table (see Finance Module’s Pledge section) and were previously invisible on this statement entirely, understating a member’s real total giving. Each line gets a Type column via one shared rule (also used by the frontend history list, see discuva-member’s giving.tsx):
- The uploaded bank account’s name when the record came from a bank-statement upload (
batch.titheAccount). - Otherwise the purpose the member chose (
givingOption— set at online checkout, or carried over from a confirmed proof of payment). - Otherwise the sender’s
bankName(legacy manual deposits). - Otherwise “General Giving”.
- A
PledgeContribution→ “Pledge: {campaign name}”.
(Changed 2026-09-30: previously an unmatched MANUAL_PROOF row was always labelled “Tithe”, so a confirmed proof the
member had marked “Offering” showed as a tithe. The same rule is applied in SQL by the History summary and by the
member app’s history list.)
Member proof list filter: GET /tithes/proof takes an optional status query — a comma list of PENDING,
CONFIRMED, DECLINED (unknown values → 400). The member app asks for PENDING,DECLINED only: a confirmed proof is
already a TitheRecord in the giving history, so listing it again would show the same gift twice. Declined proofs
return financeNote so the app can show why.
Member giving summary — GET /tithes/me/summary?year=YYYY (JwtAuthGuard; year optional, defaults to the current
year, 2000–2100): { year, years, total, count, byType: [{ type, total, count }] } for the member’s History tab.
byType covers TitheRecords (labelled with the rule above) and CONFIRMED PledgeContributions (“Pledge: {campaign}”),
largest first; years lists every year with giving plus the current year, for the year picker. Computed with two
aggregate queries (GROUP BY over tithe_records + pledge contributions, and a UNION of distinct years), both using
the (member_id, payment_date) / pledge member indexes — no rows are loaded into memory.
Month groups (2026-09-30): the statement table no longer has a Month column. Rows are grouped by month, newest first; each group opens with a shaded row carrying the month’s subtotal (useful for annual statements). Columns are Date, Type, Amount, Paid Via, Reference; references print at 7.5pt so 43-character online references fit on one line.
Paid Via column (replaces “Bank”): the sender’s bankName for transfers; for online payments the provider from
TitheRecord.paymentChannel (paystack → “Paystack”, flutterwave → “Flutterwave”, kora → “Korapay”,
stripe → “Stripe”) plus, when the provider reported one, the channel from the checkout session (e.g.
“Paystack · Card”, “Paystack · Bank Transfer”), or “Online” if an older gateway row has none. All online gifts on a
statement are resolved with a single giving_checkout_sessions lookup. Online pledge contributions carry their checkout
reference (giving_…), so the provider is looked up from giving_checkout_sessions (public schema). Other pledge
contributions show “—”. The Amount header and total are right-aligned with the figures (the total no longer repeats
the currency, which is in the header), and the table’s columns fit within the page margins.
Offering (finance_offerings) is deliberately excluded — it has no member relation (anonymous in-service collection), so it can’t be attributed to an individual’s personal statement. This is a different feature from the annual POST /finance/me/giving-statement/send (summary-only, previous calendar year, see Finance Module below) — that one already merged TitheRecord + PledgeContribution totals, just without line items or a member-triggered range.
Tithe payment proof: Members and workers submit proof of an offline tithe payment via POST /tithes/proof (multipart, field: file, max 2 MB; body field titheAccountId — the account they paid into). The file is uploaded to Cloudinary and a TithePaymentProof record is created with status PENDING and expiresAt set to TITHE_PROOF_EXPIRY_DAYS days from submission (default 90). Finance team admins review proofs at GET /admin/tithes/proofs and can CONFIRM or DECLINE each one. Confirming creates a TitheRecord (source MANUAL_PROOF, givingOption unset — the proof form doesn’t collect a purpose, so it displays as “General Giving”) so the payment shows up in the member’s own giving history and giving statement, not just the admin’s proof queue. Confirming or declining also triggers an email to the member that includes the bank name and account-level currency. A daily cron at 03:00 (church-local time, see Timezone) (with distributed Redis lock lock:tithe-proof-cleanup) finds all expired proofs (expiresAt ≤ now), deletes each file from Cloudinary using the stored publicId + resourceType, and removes the DB rows.
Routes prefix (admin): /admin/tithes
Routes prefix (member): /tithes
Finance Module
Full double-entry accounting system for the church. All financial data is fund-scoped (RESTRICTED / UNRESTRICTED). Every posted entry has balanced debit and credit lines; the balance is enforced at the service layer before posting, and a DB-level CHECK (current_balance >= -0.01) on finance_accounts is a last-resort safety net.
Core concepts:
| Concept | Description |
|---|---|
| Fund | RESTRICTED or UNRESTRICTED pool of money. Every account, offering, budget, and pledge belongs to a fund. Back-office accounting data only — never exposed to members directly (see GivingOption). |
GivingOption (finance_giving_options) |
Donor-facing “what is this gift for” selector for online giving-checkout (Tithe, Offering, General Giving, Building Fund, etc.) — admin-managed (admin/finance/giving-options, FINANCE_READ/FINANCE_WRITE), member-readable (GET finance/giving-options, active only). Each option carries an optional fund for accounting purposes only — a member never sees the fund’s id/type, matching how PledgeCampaign already surfaces only fundName as a display string, not raw Fund. A TitheRecord created from a PAYMENT_GATEWAY checkout gets givingOption set when the member picked one; null means “General Giving,” no forced default row. |
| AccountingPeriod | A calendar month (year + month). Entries can only be posted to OPEN periods. Closing a period is irreversible by design (only admins with FINANCE_RECONCILE can close or reopen). |
| Chart of Accounts | finance_accounts table. Each account has an optional unique code (e.g. 1001), a type (ASSET / LIABILITY / INCOME / EXPENSE), subtype, normal balance (DEBIT or CREDIT), and an optional fund assignment. code is nullable but unique when provided — 409 if a duplicate code is submitted. |
| JournalEntry | The root transaction record. Must be BALANCED (sum of debits = sum of credits) before posting. Created as PENDING_APPROVAL; a separate admin with FINANCE_APPROVE (who is not the creator — segregation of duties) approves and posts it. |
| JournalEntryLine | One debit or credit line on a journal entry. Linked to an account. journal_entry_id and account_id are both indexed. |
| JournalEntryLink | Polymorphic association table attaching a journal entry to members, departments, service events, external payees, or finance requests. Stored as a separate table to preserve FK integrity and allow multiple associations per transaction. linkType: FINANCE_REQUEST uses a bare financeRequestId UUID column rather than a relation, deliberately avoiding an entity import from the separate finance-request module. |
| ExternalPayee | Tracks global church remittances, vendors, utilities, contractors, government bodies. |
| Offering | Manually-recorded in-person giving (cash + expected transfer amounts). Purpose is an optional givingOption (finance_giving_options) FK — same admin-configured list checkout uses, not a separate hardcoded type — with a nullable legacy type enum column kept only for rows recorded before this unification (never written to on new entries). Omitting givingOptionId on create means “General Giving” — same convention as InitiateGivingCheckoutDto, no seeded row required. fund is derived from givingOption.fund when present; the DTO’s fundId is required whenever that resolution has nothing to fall back to (no option picked, or the picked option has no fund). Also carries an optional member (who physically brought it — null is a legitimate anonymous/basket collection) and serviceEventId (tags the entry to a specific service, no FK relation, just an indexed-free uuid column). Reconciled separately by finance team. fund_id, giving_option_id, and member_id are all indexed. |
| Budget | Scoped to an account + fund. Actuals computed at query time from posted entries. |
| PledgeCampaign / Pledge | Campaign-level targets and per-member pledge commitments. |
| RecurringEntry | Template for entries that repeat weekly / monthly / quarterly. A daily scheduler generates draft entries for due recurring templates. |
| PettyCashReplenishment | Request/approve flow for topping up petty cash accounts. Self-approve is blocked. Approving creates a PENDING_APPROVAL journal entry (debit toCashAccount, credit fromAccount) with idempotency key petty-cash-replenishment:{id}. |
| BankImportProfile | Configurable CSV parsing profile. Stores column indices, date format, delimiter, and amount convention (SIGNED, SEPARATE_COLUMNS, or AMOUNT_WITH_TYPE). One profile can be flagged isDefault. |
Race condition protection:
SELECT FOR UPDATE(pessimistic locking) onfinance_accountsrows during approval and void operations — prevents concurrent writes from losing updates.- Unique
idempotency_keycolumn onfinance_journal_entries— duplicate submissions return409 Conflict. CHECK (current_balance >= -0.01)DB constraint — last-resort guard.
Void / reversal pattern: Voiding a posted entry does NOT delete it. A new reversing entry (equal and opposite lines) is created with entryType = REVERSAL and both entries remain in the ledger. The original entry status becomes VOIDED. Voiding an entry in a CLOSED accounting period throws 400 Bad Request.
Tithe virtual accounts — removed. A dedicated-bank-account-per-member giving mechanism was scaffolded (entity,
stub service, webhook controller) but never implemented beyond NotImplementedException on every method, and the
member app’s card was labeled “Coming Soon.” Deleted entirely rather than finished — replaced by the tenant-owned
Giving Checkout flow below.
Giving Checkout (Tenant-Owned, BYOK) — src/giving-checkout/
A member pays the church directly via a hosted checkout page — Paystack, Flutterwave, Korapay, or Stripe, using the church’s own merchant credentials, never a platform account. Pure BYOK, same shape as Communication Providers: no platform default exists, so the “Give via Checkout” option is simply absent from the member app until a tenant configures and activates one provider.
Provider abstraction (src/giving-checkout/interface/giving-provider.interface.ts):
type GivingProviderCredentials = Record<string, string>; // e.g. Paystack's { secretKey }, Stripe's { secretKey, webhookSecret }
interface IGivingProvider {
readonly providerName: string;
createCheckoutSession(params: {
amountCents: number; currency: string; payerEmail: string; payerName: string;
reference: string; successUrl: string; cancelUrl: string; credentials: GivingProviderCredentials;
}): Promise<{ checkoutUrl: string }>;
verifyAndParseWebhook(rawBody: Buffer, signatureHeader: string, credentials: GivingProviderCredentials): NormalizedGivingEvent;
}
GivingProviderRegistryService (same shape as PaymentProviderRegistryService/SmsProviderRegistryService) holds
all five vendors live simultaneously; GivingCheckoutService resolves which one to use per call from the tenant’s
active TenantGivingProviderConfig.providerId. Credentials are always passed as a call parameter, never injected
from ConfigService — there is no platform merchant account behind any of these.
The four providers (src/giving-checkout/provider/) —
providerId |
Class | Credential shape | Amount unit sent to vendor |
|---|---|---|---|
paystack |
PaystackGivingProvider |
{ secretKey } |
Smallest unit (kobo) — amountCents as-is |
flutterwave |
FlutterwaveGivingProvider |
{ secretKey, secretHash } |
Major unit (naira) — amountCents / 100 |
kora |
KoraGivingProvider |
{ secretKey } |
Major unit (naira) — amountCents / 100 |
stripe |
StripeGivingProvider |
{ secretKey, webhookSecret } |
Smallest unit (cents) — amountCents as-is |
Webhook signature verification differs per vendor: Paystack HMAC-SHA512 over the raw body
(x-paystack-signature); Flutterwave a direct shared-secret string compare (verif-hash, not an HMAC); Korapay
HMAC-SHA256 over just the data object, not the full envelope (x-korapay-signature); Stripe HMAC-SHA256 over
${timestamp}.${rawBody} using a signing secret distinct from the API key, header format t=…,v1=…
(Stripe-Signature). Each is entirely self-verifying — none of the shared “never trust the payload” discipline
below depends on which scheme a given vendor uses.
Entities — all control-plane (public, never a search_path target — same reasoning as
TenantCommunicationProviderConfig/BillingCheckoutSession: the inbound webhook has no Host header/subdomain to
resolve a tenant from, only a :tenantId path param, so these must be resolvable with zero tenant (schema) context):
GivingProvider(giving_providers) — platform-wide catalog, mirrorsCommunicationProvider.TenantGivingProviderConfig(tenant_giving_provider_configs) — one row per (tenant, provider),credentialsEncrypted(jsonb,select: false,EncryptionServiceAES-256-GCM — same encryption as Communication Providers),isActive. Only one provider active per tenant at a time, enforced the identical way as Communication Providers:TenantGivingProviderService.upsertConfig()/setActive()(when activating) run inside a transaction that also deactivates every other config row for that tenant.GivingCheckoutSession(giving_checkout_sessions) — mirrorsBillingCheckoutSessionexactly: primary keyed by the provider’s own reference, recorded at checkout-initiation time (before the member ever reaches the provider’s hosted page) — the webhook only ever confirms/denies a session this row already describes, never a source of truth for amount/member/tenant identity itself.memberId/givingOptionId/pledgeIdare plain UUID columns, not FK-enforced relations —Member/GivingOption/Pledgeall live in the tenant’s own schema, which a public-schema table can’t foreign-key into.givingOptionIdandpledgeIdare mutually exclusive (see below). A legacytitheAccountIdcolumn still exists on the table but is unused — checkout no longer lets a member pick aTitheAccount(see below).- Reported charge details (root migration
AddGivingCheckoutPaymentDetails): on a successful charge the session also stores what the provider reported —providerTransactionId,paymentChannel(card, bank_transfer, ussd…),paidAt,paidAmountCents,paidCurrency,feesCents, andpaymentDetails(jsonb: card type, last 4, issuing bank, gateway message — never full card data). Only Paystack fills these so far (data.id,channel,paid_at,requested_amountfalling back toamount,currency,fees,authorization.*,gateway_response); other providers leave them null.requested_amountis used because Paystack adds its fees toamountwhen a church passes charges to the payer. - Charge check: when the provider reports an amount or currency that differs from the session, the session is set
to
needs_review(newGivingCheckoutStatus.NEEDS_REVIEW) with the reported details saved, an error is logged, and noTitheRecord/PledgeContributionis created — the money was taken, so finance resolves it rather than it being silently recorded at the wrong amount. Providers that report nothing are trusted as before.
Checkout initiation (GivingCheckoutService.initiateCheckout, member-facing, normal in-app request — tenant
context already resolved by TenantMiddleware): resolves the tenant’s active config (cached 300s per tenant,
invalidated on write — identical pattern to SmsCredentialResolverService, joined against GivingProvider and
requiring provider.isActive = true too, not just the tenant’s own config row — see “Giving Providers:
deactivation has real consequences” below), throws 403 GIVING_PROVIDER_NOT_CONFIGURED if none is active, looks
up the member for email/name, resolves currency from CURRENCY_CODE (checkout is single-currency per tenant —
there is no per-transaction currency picker), generates a giving_{uuid} reference, and calls the resolved
provider. Saves a PENDING GivingCheckoutSession row before returning { checkoutUrl }.
Why checkout has no TitheAccount/currency picker (unlike the manual proof-of-payment flow, which does): a
TitheAccount is one of the church’s real named bank accounts, meaningful only when a member is telling the
system which one they manually deposited into for reconciliation (ProofOfPaymentForm). Gateway checkout never
deposits into a specific TitheAccount — the money always settles to the tenant’s configured BYOK merchant
account for that provider — so a dropdown of account names in the checkout flow looked like it controlled where
the money went when it never did; it only silently overrode the charged currency. Removed entirely rather than
kept as “informational.”
Giving purpose designation — givingOptionId / pledgeId (mutually exclusive): a member may optionally
designate the payment at checkout, never both at once (400 Bad Request if both are given):
givingOptionId— validated against the tenant’s activeGivingOptions (404if missing/inactive). On webhook success, the resultingTitheRecord.givingOptionis set to it.pledgeId— validated as one of this member’s ownPledges withstatus = ACTIVE(404if not found/not theirs,400if not active — checkout never auto-creates a pledge on the fly). On webhook success, noTitheRecordis created at all — insteadPledgeService.recordConfirmedContribution()records aPledgeContributionwithstatus = CONFIRMEDdirectly (skipping thePENDING/admin-review stepsubmitContribution()uses for member-self-reported payments, since the webhook has already verified the money actually cleared) and runs the same pledge-auto-complete checkconfirmContribution()does. This keeps online giving and pledge fulfillment as genuinely separate ledgers — see TitheRecord’s own note above.
Neither field set → the resulting TitheRecord.givingOption is null, displayed as “General Giving.”
Giving Providers: deactivation has real consequences (added 2026-08, same pass as Communication Providers’
equivalent above). PlatformGivingProviderService.setActive() (PATCH /platform/giving-providers/:id) mirrors
PlatformCommunicationProviderService.setActive() exactly, minus the channel dimension:
TenantGivingProviderService.listProviders()excludes an inactive provider from the catalog a tenant can newly select, unless that tenant already has a config against it (kept visible —discuva-admin’s giving providers page renders one row per catalog entry, same as its communication-providers page).GivingCheckoutService.resolveActiveConfig()now joinsGivingProviderand requiresprovider.isActive = true, not justconfig.isActive. A deactivated provider genuinely stops accepting new checkout initiations. Deliberately not applied tohandleWebhook— an in-flight checkout that already charged the member on the provider’s own side must still complete and credit the church’sTitheRecordeven if the provider gets deactivated in the interim; rejecting that webhook would take the member’s money without crediting it anywhere, a worse outcome than letting one already-charged transaction finish.setActive()invalidates the 300s cache immediately for every tenant with an active config against the provider (givingProviderCacheKey, extracted as a shared utility for the same reasoncommunicationProviderCacheKeywas — two places already computed the identical string independently) and emails those tenants viaTenantBroadcastService.notifyTenants(), targeted at only the affected tenants.
A tenant’s own TenantGivingProviderConfig row is never touched by any of this.
Webhook handling (GivingCheckoutService.handleWebhook, POST /webhooks/giving/:tenantId/:provider,
@Public(), excluded from TenantMiddleware): no CLS/tenant context exists at all when this fires — tenantId
comes straight from the path param. Looks up that tenant’s own active config for :provider first (verified
credentials before anything else is trusted), decrypts, resolves the IGivingProvider, and calls
verifyAndParseWebhook() (throws on a bad signature). A non-charge.succeeded event marks the matching session
FAILED and returns — never an error response, so the provider doesn’t retry forever. On success: row-locks the
PENDING GivingCheckoutSession by the event’s own reference (idempotent against webhook redelivery — a second
delivery for an already-COMPLETED session finds nothing to lock, safe no-op), flips it to COMPLETED, looks up
the Tenant row for its schemaName, then runInTenantContext()s into that tenant’s own schema purely to write
the resulting TitheRecord (source: PAYMENT_GATEWAY, externalReference = the session id, paymentChannel =
the provider id, batch: null — same “webhook-created records have no batch” shape as reconciliation-imported
rows). This is the only place SMS/email BYOK’s “resolve credentials, dispatch to the right vendor class” pattern
and the tenant-context-entry pattern (normally only seen in Bull processors, via runInTenantContext) are combined
in the same request.
Routes:
| Method | Path | Auth | Permission | Description |
|---|---|---|---|---|
| GET | /finance/giving-providers |
AdminGuard, tenant-scoped |
TITHE_READ |
{ tenantId, catalog, ownConfigs } — ownConfigs never includes credentials. tenantId lets the frontend build this tenant’s own webhook URL ({apiHost}/v1/webhooks/giving/:tenantId/:provider, no subdomain — see webhook route below) to hand to Paystack/Flutterwave/etc, since nothing else on this tenant-scoped surface otherwise exposes the tenant’s own id to itself |
| PUT | /finance/giving-providers/:providerId |
AdminGuard, tenant-scoped |
TITHE_WRITE |
Body { credentials } — upserts and activates, deactivating any other active provider |
| PATCH | /finance/giving-providers/:providerId |
AdminGuard, tenant-scoped |
TITHE_WRITE |
Body { isActive } — enable/disable without touching stored credentials |
| GET | /finance/giving/checkout/provider |
Member JWT | — | { providerId, providerName } | null — whether to show “Give via Checkout” at all |
| POST | /finance/giving/checkout |
Member JWT | — | Body { amountCents, givingOptionId?, pledgeId?, successUrl, cancelUrl } — returns { checkoutUrl, reference } |
| GET | /finance/giving/checkout/:reference |
Member JWT | — | { status, amountCents, currency, purpose, isPledge } for the caller’s own checkout in this church (status: pending/completed/failed/needs_review; purpose: the giving option’s name, the pledge campaign’s name, or “General Giving”); 404 for anyone else’s. Polled by the Give page after the provider redirects back |
Returning from checkout (member app): before redirecting, the app keeps the returned reference in
sessionStorage. On return it reads the query tolerantly — Monnify appends ?paymentReference=… to a redirect URL that
already has ?checkout=success, and Paystack/Flutterwave echo reference/trxref/tx_ref — then polls the status
endpoint (every 2s, up to 10 times) and names what was given, e.g. “We’ve received your ₦500.00 for Tithe” or
“…toward your Building Fund pledge”: completed → thanks the member and refreshes; failed → “no gift was recorded”;
needs_review → “being checked by the finance team”; still pending → says confirmation hasn’t arrived yet without
assuming they paid (Monnify also redirects when the payer closes the page).
| Provider | Webhook → checkout status | Back to the app | Cancel |
|---|---|---|---|
| Paystack | charge.success → completed |
callback_url + reference/trxref |
metadata.cancel_action → ?checkout=cancelled (was wrongly sent as cancel_url, which Paystack ignores; billing fixed too) |
| Flutterwave | charge.completed with status: successful → completed, else failed |
redirect_url + tx_ref |
returns to the same link with status=cancelled, which the app treats as cancelled |
| Korapay | charge.success → completed |
redirect_url + reference |
— (falls back to the unconfirmed message) |
| Stripe | checkout.session.completed with payment_status: paid, or checkout.session.async_payment_succeeded → completed; async_payment_failed / expired → failed; an unpaid completed session (delayed method such as a bank debit) stays pending |
success_url, no reference (the app uses the one it saved) |
cancel_url → ?checkout=cancelled |
| Monnify | SUCCESSFUL_TRANSACTION PAID → completed; part/over-paid → needs_review |
redirectUrl + ?paymentReference= (appended as a second ?) |
— (falls back to the unconfirmed message) |
Stripe setup: the church’s Stripe webhook endpoint must subscribe to checkout.session.completed,
checkout.session.async_payment_succeeded, checkout.session.async_payment_failed and checkout.session.expired
(listed on the admin’s Giving Providers page). Without the async events a delayed payment stays pending rather than
being recorded.
| POST | /webhooks/giving/:tenantId/:provider | None (per-vendor signature) | — | Provider webhook — creates a TitheRecord on a verified successful charge |
Both finance/giving-providers and finance/giving/checkout are gated behind @RequiresModule('tithe') —
disabled entirely if a church has turned off the Tithe & Giving module.
discuva-admin’s Giving Providers page shows a read-only, copyable “Webhook URL” field per provider (inside the
same Configure/Edit Credentials panel as the credential inputs) — built client-side from tenantId (now returned
above) and NEXT_PUBLIC_API_URL, deliberately not getTenantApiBaseUrl()'s subdomain-prefixed variant, since
the webhook route itself has no subdomain to resolve a tenant from.
Platform-admin visibility (PlatformGivingProviderService, PlatformAnalyticsService.getGiving): the platform
operator’s own “full overview” across every tenant, mirroring Communication Providers’ and Billing’s existing
platform-support surfaces —
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /platform/giving-providers |
BILLING_READ |
List the platform-wide giving-provider catalog. |
| POST | /platform/giving-providers |
BILLING_WRITE |
Register a new provider — { id, name }. |
| PATCH | /platform/giving-providers/:id |
BILLING_WRITE |
{ isActive } — activate/deactivate. See “Giving Providers: deactivation has real consequences” above. |
| GET | /platform/tenants/:id/giving-providers |
BILLING_READ |
This tenant’s configured giving provider(s) and active status — never credentials. Reuses BILLING_READ (giving-checkout is a money concern) rather than adding a dedicated permission for one lookup — same reasoning now extended to the three routes above. |
| GET | /platform/analytics/giving |
ANALYTICS_READ |
?period=&months= — { period, totals, byProvider, byTenant, trend }, every array grouped by currency — completed sessions only, never blended across currencies (a Stripe/USD tenant summed against a Paystack/NGN one would be meaningless). totals is all-time; trend is windowed by months. |
PlatformAnalyticsService.getAdoption() also gained givingAdoption: ChannelAdoption (distinct-tenant count with
an active TenantGivingProviderConfig, no channel filter needed unlike SMS/email since giving-checkout has only
the one implicit channel) — same shape as the existing smsAdoption/emailAdoption.
Env vars: none — pure BYOK, no platform-default credentials for any of the five vendors, so nothing is
env-driven here at all (contrast SMS’s TERMII_BASE_URL, which stays env-driven only because it’s infrastructure,
not a secret — none of these five vendors have an equivalent fixed-but-non-secret host worth externalizing).
Monnify (Moniepoint) — monnify (added 2026-09-30, root migration AddMonnifyGivingProvider):
MonnifyGivingProvider. Credentials { apiKey, secretKey, contractCode } (Monnify dashboard → Developer → API Keys &
Contracts). Sandbox vs live is chosen by the key itself — MK_TEST_ keys use https://sandbox.monnify.com, anything
else https://api.monnify.com. Starting a checkout signs in first (POST /api/v1/auth/login, Basic apiKey:secretKey)
for a bearer token, cached in memory per key pair until a minute before it expires, then
POST /api/v1/merchant/transactions/init-transaction (amount in naira, paymentReference = our giving_… id,
card/transfer/USSD) and redirects to checkoutUrl. Webhooks go to the same v1/webhooks/giving/:tenantId/monnify
URL the admin page shows; the monnify-signature header is HMAC-SHA512 of the raw body keyed by the secret key.
SUCCESSFUL_TRANSACTION with paymentStatus PAID completes the gift; PARTIALLY_PAID/OVERPAID are passed through
with the amount actually paid so the charge check holds them as needs_review; FAILED/EXPIRED/CANCELLED/ABANDONED
fail a pending checkout; everything else (refunds, settlements) is ignored. Reported details are stored like
Paystack’s: transactionReference, channel (card / bank_transfer / ussd), paidOn, currency, fees
(amountPaid − settlementAmount) and card type/last 4. A PAID status is treated as Monnify’s confirmation of the full
amount (so passing fees to the payer doesn’t trip the check). Statements show “Monnify · Card” etc. Like Kora/Stripe,
written against Monnify’s documented API and not yet exercised against live sandbox credentials.
Not built yet: Kora/Stripe integrations are written against each vendor’s documented API shape but have not been exercised against live sandbox credentials (same “documented reasoning, not guessed silently” caveat already attached to Paystack/Flutterwave’s own subscription-webhook gaps elsewhere in this doc) — worth a live smoke test before a tenant relies on either in production. discuva-admin’s Giving Providers settings page, discuva-member’s “Give via Checkout” card, and discuva-platform’s tenant-detail “Giving Provider” panel + analytics “Giving Checkout” section are all built.
CSV reconciliation (bank statement import):
Bank Import Profiles (finance_bank_import_profiles) make CSV parsing bank-agnostic. A profile stores the delimiter, number of header rows to skip, column indices for date/narration/amount, the date format (YYYY-MM-DD, DD/MM/YYYY, DD-MM-YYYY, MM/DD/YYYY), and the amount convention:
| Convention | Description |
|---|---|
SIGNED |
Single column; negative value = debit, positive = credit |
SEPARATE_COLUMNS |
Separate debit and credit columns; whichever is non-zero wins |
AMOUNT_WITH_TYPE |
Amount column + type indicator column (e.g. DR/CR) configurable per profile |
One profile can be flagged isDefault. Upload accepts optional ?profileId query param; if omitted the default profile is used. A 400 error with {firstFailure: {row, column, expected, found}} is returned synchronously (before any job is created) if the file cannot be parsed by the selected profile — books are never affected by an unrecognisable file.
PATCH /admin/finance/reconciliation/jobs/:jobId/rows/:rowId/confirm stages a row by linking it to a ledger account (confirmedAccount). POST /admin/finance/reconciliation/jobs/:id/post-confirmed creates one PENDING_APPROVAL journal entry per confirmed row using bankAccountId + accountingPeriodId from the request body. Each row gets idempotency key reconciliation-row:{rowId}; re-calling the endpoint is safe.
Posting is batched on the read side, per-row on the write side (deliberately). ReconciliationService.postConfirmedRows resolves which rows are already posted in one batched query up front (instead of one idempotency lookup per row), but each row’s actual posting (journal entry + 2 lines + row status update) still runs in its own transaction. This is intentional, unlike the fully-batched bulk operations elsewhere in the codebase: a bad row in a bank-import batch (e.g. a stale account reference) shouldn’t block the rest of the batch from posting, so rows remain independent units of work. The idempotency_key column’s DB-level UNIQUE constraint — not the batched pre-check — is the actual guard against double-posting under a race (e.g. the endpoint invoked twice concurrently); a unique-violation on insert is caught and treated the same as “already posted.”
A row fingerprint (sha256 of date+narration+amount+creditDebit) prevents duplicate rows within the same job. A transaction fingerprint (sha256 of date+amount+creditDebit) prevents the same transaction appearing across different upload jobs.
Admin-configurable profile endpoints (FINANCE_RECONCILE permission):
POST /admin/finance/bank-import-profiles— create profileGET /admin/finance/bank-import-profiles— list all profilesGET /admin/finance/bank-import-profiles/:id— get one profilePATCH /admin/finance/bank-import-profiles/:id— update profileGET /admin/finance/bank-import-profiles/:id/template— download a pre-filled CSV template with correct column headers and two sample rows for the profile
Annual giving statements: Gated by ANNUAL_GIVING_STATEMENT_ENABLED (default false). When enabled, a cron fires on January 1st at 08:00 (with distributed Redis lock) and emails each active member a summary of their total giving for the previous year, using the annual-giving-statement.html template. Members can also trigger their own statement on demand via POST /finance/me/giving-statement/send regardless of the env var flag; this endpoint now returns a message field describing the outcome (sent, or “no recorded giving for {year} yet”).
fetchMemberTotals() sums directly from the actual giving records — TitheRecord (all of a member’s tithes in the date range) plus PledgeContribution with status = CONFIRMED (joined through Pledge for member_id) — merged in-memory by member. This intentionally does not go through finance_journal_entry_links: that link table is only ever populated by fully-manual journal entry creation (JournalEntryService) — bank reconciliation, offering auto-journal, and tithe recording never create one — so a link-based total would be 0 or wildly incomplete for almost every member. GET /admin/finance/reports/member-giving (an admin-facing report, distinct from this member-facing statement) still uses the finance_journal_entry_links path deliberately — it’s a strict “show me actual posted GL lines linked to this member” audit view, not a giving total, and carries the same underlying limitation by design until/unless tithes and offerings get their own automatic journal-linking.
The annual-giving-statement.html template was also silently rendering with a blank church name/address and no currency symbol — it referenced {{ churchName }}/{{ churchAddress }} (camelCase) and {{ currency }}, but EmailQueueService.compileTemplate() only ever injects church_name/church_address/logo_url (snake_case), and the scheduler never passed currency. Handlebars renders unresolved variables as an empty string, not literal {{ }} text, so this went unnoticed. Fixed: template now references the snake_case globals, and both sendForMember() and run() pass currency: configService.get('CURRENCY_CODE').
Recurring entry scheduler: Runs daily at 08:00 (Redis lock), looping per active tenant via forEachActiveTenant(). For each active RecurringEntry where nextDueAt ≤ now, generates a PENDING_APPROVAL journal entry in the current month’s open accounting period and advances nextDueAt to the next due date. The journal entry creation, line saves, and nextDueAt update run against the ambient per-tenant transaction (this.txHost.tx, already holding the correct SET LOCAL search_path) rather than opening a fresh dataSource.transaction(), which would silently write to the wrong schema — each entry is still wrapped in its own Postgres SAVEPOINT/RELEASE/ROLLBACK TO SAVEPOINT so one entry’s failure rolls back in isolation instead of aborting the rest of that tenant’s batch.
Vehicle-specific asset fields: Two new optional fields added to assets table:
insurance_expiry(date) — insurance policy expiry dateroadworthiness_expiry(date) — roadworthiness certificate expiry date
Eight notification-timestamp columns track when each alert was last sent (to prevent repeat alerts on re-runs):
insurance_notified_30_days_at, insurance_notified_14_days_at, insurance_notified_7_days_at, insurance_notified_1_day_at, and the equivalent four for roadworthiness_.
Vehicle expiry alert scheduler: Runs daily at 08:00 (with distributed Redis lock). For each asset that has an insuranceExpiry or roadworthinessExpiry value, alerts are dispatched at 30, 14, 7, and 1 day(s) before expiry. Each threshold is tracked by its own timestamp column; once set it prevents a duplicate alert. Recipients: admins with ASSET_MAINTENANCE_ALERT permission. Email template: asset-vehicle-expiry-alert.html.
Permissions added:
| Permission | Scope |
|---|---|
FINANCE_APPROVE |
Approve journal entries and petty cash replenishments (cannot be the creator) |
FINANCE_RECONCILE |
Upload CSV bank statements, confirm/skip reconciliation rows, close/reopen accounting periods, reconcile offerings |
FINANCE_REPORT |
Access all 8 finance reporting endpoints |
TITHE_READ |
View individual member tithe records, giving history, annual giving statements |
TITHE_WRITE |
Manage tithe accounts and this church’s giving-checkout provider credentials |
Routes prefix (admin): /admin/finance/...
| Resource | Prefix |
|---|---|
| Funds | /admin/finance/funds |
| Giving options | /admin/finance/giving-options |
| Accounting periods | /admin/finance/accounting-periods |
| Chart of accounts | /admin/finance/accounts |
| External payees | /admin/finance/external-payees |
| Journal entries | /admin/finance/journal-entries |
| Offerings | /admin/finance/offerings |
| Budgets | /admin/finance/budgets |
| Pledge campaigns + pledges | /admin/finance/pledges |
| Pledge contribution review queue | /admin/finance/pledges/contributions |
| Recurring entries | /admin/finance/recurring-entries |
| Petty cash | /admin/finance/petty-cash |
| Reconciliation (CSV upload) | /admin/finance/reconciliation |
| Bank import profiles | /admin/finance/bank-import-profiles |
| Reports | /admin/finance/reports |
Reporting endpoints (FINANCE_REPORT required, TITHE_READ for member-giving):
| Endpoint | Description |
|---|---|
GET /admin/finance/reports/income-expense |
Income & expenditure by account, filter by periodId + fundId |
GET /admin/finance/reports/cash-flow |
Line-by-line cash movement for an account (accountId required) |
GET /admin/finance/reports/trial-balance |
All accounts with current balances. Without periodId returns currentBalance from each account row. With periodId computes period-specific balances by summing posted journal lines within that period only — accounts with no activity in the period appear with balance 0. |
GET /admin/finance/reports/fund-balance |
Per-fund total balance |
GET /admin/finance/reports/account-ledger |
Full ledger for an account with date range filter |
GET /admin/finance/reports/budget-actuals |
Budget vs actual spend (budgetId required) |
GET /admin/finance/reports/pledge-summary |
Per-pledge pledged / paid / outstanding for a campaign (campaignId required). Optional fromDate/toDate add paidInPeriod (confirmed payments dated within the range). One aggregate query; flat rows so the admin report table renders them |
GET /admin/finance/pledges/contributions/download |
FINANCE_READ. Pledge payments as Excel (pledge-payments.xlsx), filtered by optional fromDate/toDate (payment date), campaignId, status. Columns: member, email, campaign, amount, payment date, Paid Via (shared giving-checkout/util/paid-via, e.g. “Monnify · Card”), reference, status, reviewed by/at, finance note |
GET /admin/finance/reports/member-giving |
Giving history for a member (memberId required, TITHE_READ) |
GET /admin/finance/reports/dashboard |
Finance dashboard snapshot: MTD income/expenses, pending entries, budget utilisation, outstanding pledges |
cash-flow, account-ledger, member-giving default to a bounded ~365-day lookback. Omitting fromDate previously scanned every posted journal line ever recorded against the account/member — each now defaults fromDate to 365 days ago (via FinanceReportService.defaultReportFromDate()) when the caller doesn’t supply one, and echoes the effective fromDate/toDate actually used back in the response so a caller can tell a default was applied. member-giving skips the default entirely when periodId is given, since a period already bounds the query. Passing an explicit fromDate (however old) is honored as-is — the default only kicks in when both date filters are omitted.
Offering reconciliation — auto-journal (optional):
PATCH /admin/finance/offerings/:id/reconcile accepts optional fields autoJournal, debitAccountId, creditAccountId, and accountingPeriodId. When autoJournal: true all three IDs are required. A double-entry journal entry is created as PENDING_APPROVAL (not auto-posted — segregation of duties: a different admin must approve via the normal journal approval flow). Idempotency key: offering-auto-journal:{offeringId}. The reconciling admin is recorded in reconciledBy on the offering. The total equals cashAmount + expectedTransferAmount. Creation runs inside a dataSource.transaction() to prevent duplicate journals under concurrent requests. Account balances are updated only when the journal entry is subsequently approved — not at creation.
Member finance endpoints (member JWT required):
| Method | Path | Description |
|---|---|---|
GET |
/finance/giving-options |
List active GivingOptions for the online-checkout “what is this for” selector — no fund exposed |
GET |
/finance/pledge-campaigns |
List active, non-lapsed pledge campaigns a member can pledge against (member-safe subset of the admin campaign shape — no createdBy) |
POST |
/finance/me/pledges |
Self-service pledge — member commits a pledge to a campaign |
GET |
/finance/me/pledges |
List the authenticated member’s pledges |
POST |
/finance/me/pledges/:id/contributions |
Log a payment claim toward one of the member’s own pledges (amount, paymentDate, optional reference) |
GET |
/finance/me/pledges/:id/contributions |
List the contribution claims (any status) for one of the member’s own pledges |
GET |
/finance/me/giving-summary |
YTD total of all TitheRecord giving (tithes, offerings, options — ytdTithes is a legacy name), active pledges, last gift. No longer used by the member app’s Pledges tab, which summarizes pledges only from GET /finance/me/pledges |
POST |
/finance/me/giving-statement/send |
Trigger annual giving statement email for the previous year (on-demand, always available) |
Pledge campaign discovery (GET /finance/pledge-campaigns): Filters to isActive = true AND endDate >= CURRENT_DATE — a campaign that’s lapsed or been deactivated is never pledge-able even if a member still has the ID. Not paginated (bounded, admin-controlled reference data, same category as departments/venues). This is distinct from GET /admin/finance/pledges/campaigns, which is admin-only and returns the full entity including createdBy.
Deactivating a campaign (PATCH /admin/finance/pledges/campaigns/:id/active, FINANCE_WRITE): Body { isActive: boolean }. This is the only way to edit a campaign after creation — there is no general update endpoint. Deactivating a campaign only removes it from GET /finance/pledge-campaigns (members can no longer start new pledges against it); it does not touch any existing pledges under that campaign, which keep whatever status/contributions they already have.
Manual pledge completion vs. contribution-confirmed completion (important distinction): PATCH /admin/finance/pledges/:id/status and the pledge-contribution confirm flow are two independent mechanisms that can both result in status: COMPLETED, and they are not reconciled with each other by design. Manually setting a pledge to COMPLETED/CANCELLED via the status endpoint does not touch amountPaid or any pending contributions — a pledge can be manually marked COMPLETED while amountPaid is still 0 and a contribution is still sitting PENDING. The only way to reach COMPLETED with amountPaid guaranteed to equal totalAmount is via the contribution-confirm auto-complete path (PledgeService.maybeAutoCompletePledge, triggered after confirmContribution). Admins using the manual status endpoint should understand it as a pure administrative override, independent of payment tracking.
Pledge self-service: MakePledgeDto requires campaignId, totalAmount, frequency (ONE_OFF | MONTHLY | QUARTERLY), startDate. Pledges created this way are identical in schema to admin-created pledges; the audit log records source: 'member-self-service'.
Pledge status transitions: COMPLETED and CANCELLED are terminal states — once a pledge reaches either status, PATCH /admin/finance/pledges/:id/status throws 400 Bad Request. This prevents accidental reactivation of fulfilled or cancelled commitments.
Pledge reminder scheduler: Runs daily at 08:00 (Redis lock lock:pledge-reminders). For each ACTIVE pledge, calculates the next due date (rolling forward from startDate by frequency). Sends a pledge-reminder email when diffDays is 7 (upcoming), 0 (due today), or −3 (overdue). Redis cache key pledge-reminder:{pledgeId}:{dueDateKey}:{diffDays} with 2-day TTL prevents duplicate sends.
Pledge contributions (tracking actual payments): Pledge.status alone never reflected whether a pledge had actually been paid — it was a manual admin flag. finance_pledge_contributions closes that gap with a claim-and-confirm flow mirroring the tithe payment-proof pattern:
POST /finance/me/pledges/:id/contributions— the pledge’s own member logs a payment claim (amount,paymentDate, optionalreference). 403s if the pledge belongs to someone else; 400s if the pledge isn’tACTIVE. Starts asPENDING.GET /admin/finance/pledges/contributions(FINANCE_READ) — paginated review queue, filterable bystatus/pledgeId/campaignId.POST /admin/finance/pledges/contributions/:id/confirm/.../decline(FINANCE_WRITE) — finance reviews each claim. Confirming stampsreviewedBy/reviewedAt, emails the member (pledge-contribution-confirmed), and re-sums that pledge’sCONFIRMEDcontributions — if the sum reachestotalAmount, the pledge is automatically flipped toCOMPLETED(no manual status click needed). Declining requires afinanceNoteand emailspledge-contribution-declined.- Only
CONFIRMEDcontributions count.amountPaid(per pledge, onGET /finance/me/pledgesandGET /admin/finance/pledges) andtotalPaid(per campaign, on both campaign-list endpoints) are always computed live fromSUM(amount) WHERE status = 'CONFIRMED'— never stored/denormalized, same approach as the existingtotalPledged/pledgeCountsubqueries onPledgeCampaign. - A pledge’s committed
totalAmountand its actually-paidamountPaidare deliberately distinct fields — a pledge can beACTIVEwithamountPaidanywhere from 0 up to (but not yet reaching)totalAmount.
Budget utilisation alerts: Runs daily at 08:00 (Redis lock lock:budget-utilization-alerts). Calculates actuals for each active budget by summing posted journal entry lines for the budget’s account within the budget date range. Sends finance-budget-alert email to all admins with FINANCE_READ permission at 80% and 100% utilisation thresholds. Dedup via alert_80_sent_at / alert_100_sent_at columns on finance_budgets (persists across Redis flushes). Each threshold fires at most once per budget.
Finance dashboard summary (GET /admin/finance/reports/dashboard, FINANCE_REPORT permission):
Returns a point-in-time snapshot:
mtdIncome/mtdExpenses/mtdNet— month-to-date totals from posted journal linespendingJournalEntries— count of entries inPENDING_APPROVALstatuspendingPettyCash— count of replenishments inPENDINGstatusbudgetsNearLimit— all active budgets ≥ 80% utilised (sorted desc), each withname,amount,actuals,utilizationPcttotalOutstandingPledges/activePledgeCount— sum and count ofACTIVEpledgesgeneratedAt— server timestamp
Environment variables added:
| Variable | Default | Description |
|---|---|---|
ASSET_OVERDUE_NOTIFICATION_DAYS |
1,3,7 |
Comma-separated days-overdue thresholds for checkout reminders. Empty string disables. |
ANNUAL_GIVING_STATEMENT_ENABLED |
false |
Set to true to enable the Jan 1 batch annual giving statement emails to all members |
Entities: finance_funds, finance_accounting_periods, finance_accounts, finance_external_payees, finance_journal_entries, finance_journal_entry_lines, finance_journal_entry_links, finance_offerings, finance_budgets, finance_pledge_campaigns, finance_pledges, finance_recurring_entries, finance_petty_cash_replenishments, finance_bulk_upload_jobs, finance_reconciliation_rows, finance_bank_import_profiles. New FK on finance_offerings: reconciled_by_id. New FK on finance_bulk_upload_jobs: profile_id. New columns on tithe_records: source, external_reference, payment_channel; batch_id is now nullable (webhook-created records have no batch). New columns on assets: insurance_expiry, roadworthiness_expiry, plus 8 notification-timestamp columns (insurance_notified_*, roadworthiness_notified_*).
Giving Checkout’s three entities (giving_providers, tenant_giving_provider_configs, giving_checkout_sessions
— §9 Phase 9h) are deliberately not finance_*-prefixed despite living under src/giving-checkout/ — they’re
control-plane (public schema), same category as communication_providers/billing_checkout_sessions, not
tenant-schema finance_* data.
member_virtual_accounts (and tithe_records.virtual_account_id) existed here through 1784592000000-CreateMemberVirtualAccountsAndTitheSource but were dropped by tenant migration 1792108800000-DropMemberVirtualAccounts — see “Tithe virtual accounts — removed” above.
Migrations:
1783641600000-CreateFinanceFunds1783728000000-CreateFinanceAccountingPeriods1783814400000-CreateFinanceAccounts1783900800000-CreateFinanceExternalPayees1783987200000-CreateFinanceJournalEntries1784073600000-CreateFinanceOfferings1784160000000-CreateFinanceBudgets1784246400000-CreateFinancePledges1784332800000-CreateFinanceRecurringEntries1784419200000-CreateFinancePettyCash1784505600000-CreateFinanceBulkUpload1784592000000-CreateMemberVirtualAccountsAndTitheSource1784678400000-AssetVehicleFields1784764800000-AssetVehicleNotificationColumns1784851200000-TitheRecordBatchNullable1784937600000-AddTimestampsToJournalEntryLinesAndLinks1785024000000-BudgetAlertColumns1785110400000-CreateBankImportProfiles(createsfinance_bank_import_profiles+ seeds canonical default profile)1785196800000-BulkUploadJobProfileFK(addsprofile_idnullable FK tofinance_bulk_upload_jobs)1785283200000-OfferingReconciledBy(addsreconciled_by_idnullable FK tofinance_offerings)
Finance Request Module
Manages expense requests raised by department heads (HODs) through a finance team review lifecycle.
Lifecycle: PENDING → APPROVED / REJECTED. On approval, the finance team attaches proof of payment via a
separate PATCH /:id/proof endpoint.
Self-approve guard: An admin cannot approve a request they submitted. Returns 403 Forbidden.
Proof replacement: If PATCH /:id/proof is called on a request that already has a proof file, the old Cloudinary asset is deleted before uploading the new one. If the delete fails (network error, already removed), the error is logged and the upload proceeds anyway — the old asset may be orphaned but the request is not blocked.
Posting to the ledger (PATCH /:id/proof with postToJournal: true): optional — proof-attachment time, not
approve/reject, is when this is offered, since that’s when there’s actual evidence money moved (reject never moves
money; approval alone doesn’t either). When set, the caller also sends debitAccountId (an active EXPENSE
account) and creditAccountId (the account paid from) in the same multipart body. Mirrors
PettyCashReplenishment’s approve-time posting exactly: creates one PENDING_APPROVAL JournalEntry with two
JournalEntryLines (DEBIT the expense account, CREDIT the paying account, both for request.amount) and a
JournalEntryLink (linkType: FINANCE_REQUEST, role: RECIPIENT, financeRequestId) back to the request, guarded
by idempotency key finance-request:{id} — a repeat call with postToJournal: true on an already-posted request
just re-links the existing entry rather than erroring or duplicating it. FinanceRequest.journalEntry is set to the
created entry. A second, different admin still has to approve the entry via the normal Journal Entries flow
(PATCH /admin/finance/journal-entries/:id/approve) before it posts to Account.currentBalance — segregation of
duties is unchanged. Requires an OPEN AccountingPeriod for the current month (400 otherwise) and the tenant’s
plan to include PlanFeature.FINANCE (403 PLAN_UPGRADE_REQUIRED otherwise) — proof upload without posting stays
available regardless of plan, since FinanceAdminController itself carries no PlanGuard.
Email notifications:
- On creation → all active admins with
FINANCE_WRITEpermission are notified (filtered in SQL viaANY(r.permissions)) - On approve/reject/proof → the HOD who raised the request is notified
HOD enforcement: Only workers with a lead assignment (DepartmentLead record) can create or view department
requests. A worker can only raise a request for their own department (verified server-side).
Paid (derived, not stored): the stored status stays APPROVED after the finance team attaches the payment proof
(PATCH /admin/finance/requests/:id/proof). Every loaded FinanceRequest carries a computed isPaid
(status === APPROVED && proofUrl, set in an @AfterLoad hook, and on the response of the proof upload). The admin
portal and the HOD’s member-app list show “Paid” when it’s true; the HOD also gets a “View payment proof” link and, on
rejected requests, the rejection reason. The Excel export’s Status column reads PAID for these rows. Budget and
report figures that count APPROVED requests are unaffected.
Admin list filters: GET /admin/finance/requests now accepts additional query params for richer filtering:
| Param | Type | Description |
|---|---|---|
status |
enum | PENDING | APPROVED | REJECTED, or AWAITING_PAYMENT (approved, no payment proof yet) / PAID (approved with a payment proof). APPROVED still means every approved request. Unknown values → 400 |
categoryId |
UUID | Filter to a specific expense category |
memberId |
UUID | Filter to requests raised by a specific member |
departmentId |
UUID | Filter to requests raised by a specific department |
search |
string | Wildcard match on requester name, email, or reason text |
page / limit |
int | Pagination (default 1 / 20) |
GET /admin/finance/requests/download accepts the same filters (no pagination) and returns an .xlsx file with columns: Requester, Email, Department, Category, Amount (NGN), Status, Reason, Reviewed By, Reviewed At, Rejection Reason.
Routes prefix (admin): /admin/finance
Routes prefix (worker): /finance
Follow-Up Module
Handles first-timer registration, follow-up task management, and post-event engagement workflows.
First-timer registration is available on both the worker mobile app (workers in the FOLLOW_UP department) and the admin portal (admins with FOLLOW_UP_WRITE). On creation, a FollowUpTask of type FIRST_TIMER is automatically created and assigned via round-robin to the FOLLOW_UP-department worker with the fewest open tasks. The pick and task creation run inside a single transaction protected by a PostgreSQL advisory lock (pg_advisory_xact_lock(hashtext('follow-up:round-robin'))), serializing concurrent registrations so the open-task count is always accurate.
When no active FOLLOW_UP worker exists to assign, this is not an error — doCreateFirstTimer() still creates the FirstTimer and its FollowUpTask with assignedTo: null (nullable since migration MakeFollowUpTaskAssignedToNullable; was NOT NULL originally, which would have hard-blocked creation). The returned FirstTimer carries a transient assignmentWarning: string | null (not persisted — same pattern as visitCount) so callers can surface a warning instead of silently losing the registration. The admin UI shows it as a dismissible banner instead of auto-closing the “Add First Timer” panel. An unassigned task shows up in GET /admin/follow-up/tasks like any other (with assignedTo: null, “—” for worker name) and can be handed to someone once a worker becomes active via PATCH /admin/follow-up/tasks/:id/reassign.
Self-onboarding from the member app’s signup screen (POST /follow-up/public/first-timer, FollowUpPublicController): @Public(), no login required — reached from discuva-member’s own signup page by someone who just installed the app and isn’t (yet, or ever) ready to create a full member account, so they can still let the church know they’re here. Mirrors FormPublicController’s shape (module gate via ModuleEnabledGuard, rate-limited @Throttle({limit: 5, ttl: 60_000}) since it’s an open unauthenticated write). Calls FollowUpService.createFirstTimerFromAppSignup(), which — like createFirstTimerFromPublicForm forcing ONLINE — forces source to a new FirstTimerSourceEnum.APP_SIGNUP value regardless of what the caller submits, and passes an empty actor (no createdByMember/createdByAdmin). Goes through the exact same doCreateFirstTimer path as every other first-timer creation route: round-robin FollowUpTask assignment, due date, fire-and-forget assignment email. Returns only { received: true, firstTimerId } — never the assignee or other internal details, to an unauthenticated caller.
“Which event is this for?” picker (GET /follow-up/public/events, FollowUpPublicController.events; and the existing authenticated GET /events?from=&to= for admin/worker callers): Backs the event-visited field on both the unauthenticated member-app self-onboarding form above and the admin’s manual “Add First Timer”/“Log Visit” forms. FollowUpService.getPublicEvents(search?) defaults to today’s events plus the last 14 days (event.eventDate <= today AND event.endDate >= today − 14d, never future events, newest first, max 10; “today” is computed in CHURCH_TIMEZONE — both event_date and end_date are indexed, migration AddEventEndDateIndex) so a visitor can tap the service they attended instead of typing — a name search (?search=, also past/today only) is the fallback for older services. Each option returns { id, name, eventDate, endDate, isToday } (dates as YYYY-MM-DD); the member app shows “Today” or the weekday + date (a range for multi-day events) beside each name. Public variant is @Public() + ModuleEnabledGuard (module follow_up) + @Throttle({limit: 30, ttl: 60_000}) (read-only, higher limit than the write endpoint above since it’s typeahead-driven), and selects only id/name/eventDate/endDate — no attendance, slot, or venue detail, since the caller isn’t authenticated. The admin UI instead reuses the existing authenticated GET /events route with from/to set to today for the same “today” default, since an admin session already has full read access to that endpoint.
Editing a first-timer’s own details (PATCH /admin/follow-up/first-timers/:id, admin; PATCH /follow-up/first-timers/:id, worker; both UpdateFirstTimerDto): for correcting a record after the fact — e.g. an admin forgot to set the event visited, or a Sunday School check-in only captured partial info. All fields optional/independent (firstname, lastname, phone, email, wantsToJoinChurch, wantsToJoinWorkforce, enjoyedAboutChurch, notes, visitedEventId); source is deliberately not editable here — it’s forced server-side at creation to stay non-spoofable, and changing it after the fact would corrupt source attribution in reports. convertedAt/inviteSentAt have their own dedicated endpoints. FollowUpService.updateFirstTimer() reloads the record with its visitedEvent relation before returning, so the response reflects the current event name, not just the id that was set. The worker variant (updateFirstTimerByWorker) is a thin wrapper adding assertWorkerInFollowUpDept first — same shape as getFirstTimerDetailForWorker — and isn’t scoped to only first-timers on the caller’s own tasks, matching createFirstTimerByWorker’s existing department-wide (not just own-task) access. Backs an inline “Edit Details” toggle on the member app’s task detail screen, using the same public today-first event picker (GET /follow-up/public/events) as the self-onboarding form, since it’s @Public() and works fine from an authenticated session too.
First-timer visit history (GET /admin/follow-up/first-timers/:id, admin; GET /follow-up/first-timers/:id, worker): Returns { firstTimer, visitCount, timeline, convertMatches, linkedConvert } — FollowUpService.getFirstTimerDetail() (worker variant wraps it with assertWorkerInFollowUpDept first). timeline merges three sources into one dated list, each entry { source: 'INITIAL_VISIT' | 'LOGGED_VISIT' | 'SUNDAY_SCHOOL' | 'OUTREACH_MET' | 'EVANGELISM_FOLLOW_UP', label, occurredAt, notes? }: the first-timer’s own createdAt as INITIAL_VISIT; each FirstTimerVisit row as LOGGED_VISIT; and each SundaySchoolAttendance row linked via first_timer_id as SUNDAY_SCHOOL (queried directly — SundaySchoolAttendance is registered read-only in FollowUpModule rather than importing SundaySchoolModule, which would be circular since it already imports FollowUpModule). visitCount counts only the three visit sources — the outreach entries (see “Outreach convert → first-timer” below) are history, not visits. The admin route is declared after first-timers/pipeline in FollowUpAdminController so that static path keeps matching first. getFirstTimers (the list endpoint) carries a lighter version of the same idea: loadRelationCountAndMap('ft.visitCount', 'ft.visits') for the logged-visit count, then one batched SundaySchoolAttendance count query (first_timer_id IN (:...ids), grouped) across the whole page — never per-row — plus +1 per row for the initial visit.
Post-event jobs (Bull queue follow-up):
- After
markAbsentees()completes for an event, apost-eventBull job is dispatched. PostEventProcessor.handlePostEventsends thank-you emails to all PRESENT/LATE members ifevent.thankYouSentAtis null, then setsthankYouSentAt— preventing duplicate sends on re-trigger.- If
event.onlineAttendanceEnabled = true: sends online-confirm request emails to ABSENT members (button links to the service’s page in the church’s member app —resolveMemberUrl('/events/:id'); a signed-out member is returned there after sign-in), setsevent.onlineNotificationSentAtandevent.onlineConfirmClosesAt, and schedules aonline-window-closeddelayed job (ONLINE_CHECKIN_WINDOW_HOURShours later, default 3). handleOnlineWindowClosedcreatesONLINE_NO_RESPONSEfollow-up tasks for all members still marked ABSENT.
Online confirm flow:
Members receive an email after an online-attendance-enabled event. They confirm via POST /attendances/online-confirm { eventId }. The system:
- Checks
event.onlineAttendanceEnabled = true - Validates that
now ≤ onlineConfirmClosesAt(set alongsideonlineNotificationSentAtwhen the emails go out, from the church’s window —church_settingskeyattendance:online_confirm_window_minutes, managed atGET/PATCH /attendances/settings/online-window, else envONLINE_CHECKIN_WINDOW_HOURS; events from before the column existed fall back toonlineNotificationSentAt + current window) - Finds the ABSENT record for
(member, event)and updates status toATTENDED_ONLINE
Task assignment email: When a FollowUpTask is created (first-timer registration or online non-responder) or reassigned, an email is sent to the assigned worker using the follow-up-task-assigned template. Includes the first-timer’s name, phone, email, and due date. Fire-and-forget via the email Bull queue.
Overdue escalation (daily cron at 08:00): FollowUpScheduler.escalateOverdueTasks runs every day at 08:00. It finds all tasks with status PENDING or IN_PROGRESS where dueDate < NOW(). Each affected worker receives a digest email (follow-up-overdue-worker) listing all their overdue contacts. All active admins with FOLLOW_UP_WRITE permission receive a summary count email (follow-up-overdue-admin).
Inactive task detection (daily cron at 09:00): FollowUpScheduler.notifyInactiveTasks runs every day at 09:00. It finds open tasks whose lastActivityAt < NOW() - FOLLOW_UP_STALE_DAYS (default 7 days). All active admins with FOLLOW_UP_WRITE permission receive a count email (follow-up-stale-admin). GET /admin/follow-up/tasks/stale also exposes this list on demand.
Due date: Tasks auto-set dueDate = createdAt + FOLLOW_UP_DUE_DAYS (default 3 days).
Pastoral report: GET /admin/follow-up/report?from=&to= (requires FOLLOW_UP_READ) returns aggregate stats: first-timer totals, source breakdown, wants-to-join counts, task status/outcome breakdown, overdue snapshot, conversion rate, per-worker performance, and per-event first-timer counts. Date range is optional; omitting it returns all-time stats.
Membership invitation: POST /admin/follow-up/first-timers/:id/invite-to-membership (requires FOLLOW_UP_WRITE) queues a personalised invitation email to the first-timer. Returns { queued: true } on success or { queued: false } if the invitation was already sent (inviteSentAt is set). Throws 404 if the first-timer is not found or 400 if no email address is on record. Sets FirstTimer.inviteSentAt on first send to prevent duplicate emails.
First-timer conversion: PATCH /admin/follow-up/first-timers/:id/mark-converted (requires FOLLOW_UP_WRITE) marks a first-timer as having joined the congregation. Accepts an optional memberId (UUID) body field to link the first-timer to their new Member record. Sets FirstTimer.convertedAt and optionally FirstTimer.convertedMember. When a memberId is given and an outreach convert is linked to this first-timer, that convert is marked joined too (member/linkedAt, audit CONVERT_LINKED_TO_MEMBER with metadata.via = 'first_timer') — the church records joining once. The reverse also holds: Evangelism’s PATCH evangelism/converts/admin/:id/link-member sets convertedAt/convertedMember on the linked first-timer if it isn’t converted yet.
Outreach convert → first-timer (FirstTimerConvertService): someone met on outreach (an evangelism Convert) who later visits is registered as a new FirstTimer; Follow-Up confirms whether the two are the same person — nothing links automatically.
- Suggestions —
convertMatcheson the first-timer detail: up to 5 converts not yet linked to a first-timer or member, not infirst_timers.dismissed_convert_ids, whose E.164phoneequals the first-timer’s, or (when the convert has no phone) whosenameequalsfirstname lastnamecase-insensitively; each carriesmatchedOn: 'phone' | 'name'. The list endpoint addshasConvertMatchper row from one batched query for the page. - Confirm —
POST /follow-up/first-timers/:id/link-convert(Follow-Up dept worker) /POST /admin/follow-up/first-timers/:id/link-convert(FOLLOW_UP_WRITE), body{ convertId }.409if the convert is already linked or already a member, or the first-timer already has a convert (converts.first_timer_idis unique). Setsconverts.first_timer_id/first_timer_linked_at, clears the convert’sassignedTo(theFollowUpTasknow owns the follow-up), logs aConvertFollowUpLog“Visited church as a first-timer…”, auditsCONVERT_LINKED_TO_FIRST_TIMER, flushes both report caches and pushesCONVERT_VISITED_CHURCHto the convert’s previous assignee, onboarder and outreach team (not the actor). - Dismiss —
POST …/first-timers/:id/dismiss-convert{ convertId }appends todismissed_convert_idsso it isn’t suggested again. - Unlink —
DELETE …/first-timers/:id/link-convertundoes a wrong match; the convert returns to Evangelism unassigned. AuditCONVERT_UNLINKED_FROM_FIRST_TIMER. - Timeline — once linked, the first-timer detail
timelinestarts withOUTREACH_MET(outreach title, team innotes, at the convert’screatedAt) and oneEVANGELISM_FOLLOW_UPper evangelism contact logged before the hand-over. Convert,ConvertFollowUpLogandMemberare registered inFollowUpModuledirectly — importingEvangelismModulewould be circular (Evangelism → Member → FollowUp).
Admin task update: PATCH /admin/follow-up/tasks/:id (requires FOLLOW_UP_WRITE) lets an admin update any task’s status, outcome, outcomeNotes, dueDate, and add a noteContent (with optional contactMethod) note. Unlike the worker endpoint, this is not restricted by assignment. Also sets lastActivityAt.
Worker standalone note: POST /follow-up/tasks/:id/notes (FOLLOW_UP dept worker) adds a note with an optional contactMethod (PHONE_CALL | WHATSAPP | IN_PERSON | SMS | EMAIL) without requiring a status change. Updates lastActivityAt.
Return visit tracking: POST /admin/follow-up/first-timers/:id/visits (requires FOLLOW_UP_WRITE) records that a first-timer attended again. Body: { eventId?, notes?, visitedAt? } — visitedAt defaults to today.
No dedicated first-timer SMS route. Texting first-timers is done by adding them to a Group (see Groups Module’s phone-only entries, sourced “from First-Timers” over a date range) and sending via POST /announcements/sms-broadcast with audience: GROUP — this superseded a former one-off POST /admin/follow-up/first-timers/sms route, consolidating all SMS sending into the Announcements module.
Pipeline report: GET /admin/follow-up/first-timers/pipeline?from=&to= (requires FOLLOW_UP_READ) returns a funnel breakdown: { total, untouched, contacted, returned, invited, converted }. Each first-timer is placed in the highest stage they have reached.
Stale task list: GET /admin/follow-up/tasks/stale?daysInactive=7&page=1&limit=20 (requires FOLLOW_UP_READ) returns open tasks with no activity for ≥ N days, ordered oldest-activity-first.
Reassigning a task (PATCH /admin/follow-up/tasks/:id/reassign, { workerProfileId }): for when the currently-assigned worker leaves, goes inactive, or the round-robin pick just isn’t right — moves a task to a different worker. FollowUpService.reassignTask() requires the target to both have the MANAGE_FOLLOW_UP capability (primary or secondary department) and be ACTIVE — the same two conditions pickRoundRobinAssignee enforces for automatic assignment, so a manual reassign can’t put a task on someone the round-robin logic itself would never pick. GET /admin/follow-up/workers (also FOLLOW_UP_READ, not DEPARTMENTS_READ, so a Follow-Up-only admin doesn’t need department access to use it) backs the picker for this — returns the same active/capability-filtered worker list, unpaginated (small team).
Routes (worker mobile): /follow-up/first-timers, /follow-up/tasks/mine, /follow-up/tasks/:id, /follow-up/tasks/:id/notes
First-timer list filtering: GET /admin/follow-up/first-timers accepts optional dateFrom and dateTo (YYYY-MM-DD) to restrict results to first-timers registered within that date range. Both are optional; omitting either removes the respective bound.
Routes (admin portal): /admin/follow-up/first-timers, /admin/follow-up/first-timers/pipeline, /admin/follow-up/first-timers/:id/invite-to-membership, /admin/follow-up/first-timers/:id/mark-converted, /admin/follow-up/first-timers/:id/visits, /admin/follow-up/tasks, /admin/follow-up/tasks/:id, /admin/follow-up/tasks/stale, /admin/follow-up/tasks/:id/reassign, /admin/follow-up/tasks/bulk, /admin/follow-up/report, /admin/follow-up/workers
Evangelism Module
Tracks converts from initial outreach contact through to becoming a church member — distinct from the Follow-Up
module above, which is scoped to first-timers who visited a service. A convert here is not assumed to be an
existing Member; they may just be a name and phone number an outreach worker captured in the field.
Entities:
Outreach(outreaches) — one outing:title/location(nullable),outreachDate(date, defaults to the church’s today viaDateService.today()),createdBy(SET NULL; also exposed ascreatedById) /createdByName(snapshot), andteam(ManyToMany →Memberviaoutreach_team(outreach_id, member_id), both CASCADE). Evangelism is usually done in twos or threes, so the team is recorded once per outing rather than re-tagged on every convert. The creator is always on the team and can’t be removed from it.Convert(converts) —name,phone(nullable, E.164, indexed for the duplicate check),notes(nullable),status(UNSAVED|SAVED|UNDERGOING_DISCIPLESHIP, defaultUNSAVED),onboardedBy/onboardedByName(who added them, snapshotted),outreach(nullable, SET NULL — null means the adder went alone),assignedTo(ManyToOne →WorkerProfile, nullable SET NULL — who owns the follow-up),member/linkedAt(set once the convert becomes an actualMember, mirrorsfirst_timers.converted_member_id/converted_at),firstTimer/firstTimerLinkedAt(set when Follow-Up confirms the convert visited church — see the Follow-Up module’s “Outreach convert → first-timer”; unique, SET NULL),lastContactedAt(denormalized, updated on every new follow-up log).ConvertFollowUpLog(convert_follow_up_logs) — one row per contact attempt:convert(CASCADE),loggedBy/loggedByName,note(nullable),contactedAt— mirrors theFirstTimerVisitidiom.
A convert’s outreach team is its outreach’s team plus onboardedBy.
Journey stage (stage on every list row): JOINED (linked to a member) → WITH_FOLLOW_UP (visited church;
Follow-Up owns the follow-up) → FOLLOWED_UP (any contact logged) → MET. Once WITH_FOLLOW_UP, the convert is
read-only for Evangelism: follow-up logging and status changes return 409
(assertCanActOnConvert(…, { write: true }); history uses write: false), admin reassign returns 409, bulk
reassign skips it, isOverdue is false, and it’s excluded from every “open” count (member_id IS NULL AND first_timer_id IS NULL): the overdue filter, auto-assign round-robin load, searchWorkers.openAssigned, bulk
“move all open”, and the report’s needsFollowUp/unassigned/assignedOpen. List filter
stage=open|with_follow_up|joined. The converts CSV gains a Stage column.
Settings (EvangelismSettingsService, stored as the church_settings row evangelism:settings — same
pattern as SundaySchoolSettingsService, cached 5 min):
overdueDays(1–90, default 7) — a convert not yet linked to a member and not contacted for longer than this (or never) “needs follow-up”. Used by theisOverdueflag, theoverduelist filter and the report.autoAssign(defaulttrue) — off leaves new converts unassigned for an admin to assign.
Access model:
- Adding a convert / starting an outreach — any
WORKER(RolesGuard). Onlynameis required for a convert. - Acting on a convert (
POST :id/follow-up,PATCH :id/status,GET :id/follow-up-history) —ConvertService.assertCanActOnConvert(): the onboarder, anyone on its outreach team, the assignee, or a worker whose primary or secondary department hasMANAGE_EVANGELISM_CONVERTS. Anyone else gets403. - Listing —
GET evangelism/converts?scope=mine(default) is open to any worker and returns converts where the caller is the onboarder, on the outreach team, or the assignee.scope=teamreturns every convert and requires theMANAGE_EVANGELISM_CONVERTScapability. Each row carriesmyRoles(onboarder|team|assignee) for the caller. This replaces the oldGET evangelism/converts/team. - Admin portal —
AdminGuard+EVANGELISM_READ/EVANGELISM_WRITE. Admins can assign to any active worker (WorkerProfileandMemberbothACTIVE, else400), not only the Evangelism department.
Auto-assignment on create (ConvertService.pickAssignee, skipped when autoAssign is off): the adder if
they have the capability, else the first outreach teammate who does, else round-robin to the active
capability worker with the fewest open (not-yet-linked) converts — the same shape as
FollowUpService.pickRoundRobinAssignee. No candidate → unassigned.
Duplicate check: when phone is given and allowDuplicate isn’t true, an existing convert with the same
E.164 phone returns 409 { code: 'CONVERT_DUPLICATE', existing: { id, name, onboardedByName, createdAt } }. The
client then offers POST evangelism/converts/:id/met-again (any worker; logs a follow-up prefixed “Met again”)
or a retry with allowDuplicate: true.
List filters (shared by the member list, the admin list and the converts export via
ConvertService.buildConvertQuery): status, assignedTo (workerProfileId | unassigned | me — me
is member-only, 400 in the admin portal), overdue=true, outreachId, search (name or phone, ILIKE),
from/to (created date), page/limit (max 100). Ids are paged with DISTINCT first, then loaded with
relations, because the outreach-team join is many-to-many. Team members are returned as
{ id, firstname, lastname } only.
Admin management:
PATCH converts/admin/:id/reassign{ workerProfileId },PATCH converts/admin/:id/unassign.PATCH converts/admin/bulk-reassign{ toWorkerProfileId, convertIds? (≤500) | fromWorkerProfileId? }— exactly one source;fromWorkerProfileIdmoves all of that worker’s open converts (e.g. when they step down). OneUPDATE; returns{ updated }.PATCH converts/admin/:id/outreach{ outreachId | null }— re-file or detach a convert.PATCH outreaches/admin/:id/team{ teamMemberIds }— fix an outreach team (members can do the same from the app viaPATCH outreaches/:id/teamif they’re on it).GET outreaches/admin?from=&to=— up to 200 outreaches with their teams, for filters and pickers.GET converts/admin/workers?q=/GET evangelism/workers?q=— oneOutreachService.searchWorkersmethod behind both: up to 20 active workers as{ memberId, workerProfileId, firstname, lastname, isEvangelism, openAssigned }, Evangelism workers first; the member route excludes the caller.
Report (GET evangelism/report?from=&to=, EVANGELISM_READ; default last 90 days; cached 5 min under
evangelism:report:*, flushed on every convert/outreach/follow-up write):
summary—added,byStatus(of converts added in range),visitedChurch(first_timer_linked_atin range),joinedChurch(linked_atin range),followUpsLogged,outreaches(in range);needsFollowUpandunassignedare current, not range-bound.trend—[{ period, added, visited, joined }], weekly buckets up to 26 weeks, monthly beyond.byWorker—outreaches(team memberships in range),broughtIn(converts in range where they were the adder or on the team, counted once),joinedChurch(of those, now linked),followUpsLogged(in range);assignedOpen/overdueare current.byOutreach— date, title, location,teamSize,converts,saved,discipleship,visitedChurch(linked to a first-timer or already a member),joinedChurch.
Export (GET evangelism/export?type=converts|workers|outreaches&…, EVANGELISM_READ +
@RequiresPlan(BULK_EXPORT)): one CSV route rather than a format flag per endpoint, because PlanGuard gates
per route. converts takes the list filters (unpaged, capped at 5,000 rows); workers/outreaches take
from/to and reuse the report rows. Cells are quoted; free text starting with = + - @ (other than a plain
number such as an E.164 phone) is prefixed with ' to stop spreadsheet formula injection.
Notifications — push only, category EVANGELISM (EMAIL_EVANGELISM_ENABLED, default true; per-church
switch in notification settings), never sent to the actor:
OUTREACH_TEAM_ADDED— to members added when an outreach is created or its team is edited (only newly added).CONVERT_ASSIGNED— to the assignee on auto-assign (unless they added it) and on admin reassign.CONVERTS_BULK_ASSIGNED— one push with the count to the target of a bulk reassign.CONVERT_VISITED_CHURCH— to the previous assignee, onboarder and outreach team when Follow-Up confirms the convert came to church.
Audit actions: CONVERT_CREATED (metadata outreachId, assignedTo), CONVERT_STATUS_UPDATED,
CONVERT_FOLLOW_UP_LOGGED, CONVERT_REASSIGNED, CONVERT_UNASSIGNED, CONVERTS_BULK_REASSIGNED,
CONVERT_OUTREACH_CHANGED, CONVERT_LINKED_TO_MEMBER, OUTREACH_CREATED, OUTREACH_TEAM_UPDATED,
EVANGELISM_SETTINGS_UPDATED, plus CONVERT_LINKED_TO_FIRST_TIMER/CONVERT_UNLINKED_FROM_FIRST_TIMER from
Follow-Up. Admin actions log the admin’s member id as the actor.
Routes (member app, workers): POST evangelism/converts, POST evangelism/converts/:id/met-again,
GET evangelism/converts?scope=&status=&assignedTo=&overdue=&outreachId=&search=&from=&to=&page=&limit=,
POST evangelism/converts/:id/follow-up, PATCH evangelism/converts/:id/status,
GET evangelism/converts/:id/follow-up-history?page=&limit=, GET evangelism/workers?q=,
POST evangelism/outreaches, GET evangelism/outreaches/recent (outreaches the caller is on, last 14 days —
the app pre-selects today’s so teammates on different phones add into the same outreach),
PATCH evangelism/outreaches/:id/team
Routes (admin portal): GET evangelism/converts/admin?…filters…, GET evangelism/converts/admin/workers?q=,
PATCH evangelism/converts/admin/bulk-reassign, PATCH evangelism/converts/admin/:id/reassign,
PATCH evangelism/converts/admin/:id/unassign, PATCH evangelism/converts/admin/:id/outreach,
PATCH evangelism/converts/admin/:id/link-member, GET evangelism/converts/admin/:id/follow-up-history,
GET evangelism/outreaches/admin?from=&to=, PATCH evangelism/outreaches/admin/:id/team,
GET|PATCH evangelism/settings/admin, GET evangelism/report?from=&to=, GET evangelism/export?type=…
Sermon Module
A link-based sermon archive — no file uploads. Each Sermon (title, speakerName, date, optional description,
series, youtubeUrl, mixlrUrl) stores links to where the content actually lives (YouTube, Mixlr) rather than
hosting media itself. At least one of youtubeUrl/mixlrUrl is required on create, and an update that would clear
both (leaving neither set) is rejected with 400 — a sermon archive entry with no link anywhere is not useful.
series is a plain string tag, not its own entity — deliberately, since nothing in this feature needs series-level
metadata beyond a filterable label. Paginated (grows unboundedly, same policy as members/attendance).
“Announce Live” manual trigger (POST admin/sermons/announce-live): the MVP for “auto-trigger an announcement
when we go live” — an admin clicks “We’re Live on YouTube” or “We’re Live on Mixlr” on the Sermons page, providing
the livestream URL. This calls AnnouncementService.createSystemAnnouncement() directly (see Announcements Module)
with a default title (🔴 Live Now on YouTube / 🔴 Live Now on Mixlr, overridable) and a body containing the URL.
Zero external-API risk, works identically for both platforms today, and doubles as the fallback path for the planned
YouTube WebSub automation (a channel actually going live still needs a human-clickable escape hatch for when the
automated detection misses a stream or a platform’s API is unavailable).
Routes (admin, AdminGuard): POST/GET/PATCH/DELETE admin/sermons(/:id for single-record routes) —
SERMON_READ/SERMON_WRITE; POST admin/sermons/announce-live — SERMON_WRITE.
Routes (member, JwtAuthGuard + @RequiresModule('sermons')): GET sermons?page=&limit=&series=,
GET sermons/:id — any authenticated member/worker, no department or class gating (sermons are for everyone).
Sermon notes: now stored in the Notes Module (notes table). The original GET/PUT/DELETE sermons/:id/note
routes still work for older app versions and are backed by NotesService (plain text in, plain text out; the latest
note linked to that sermon). The sermon_notes table was copied into notes by the tenant migration CreateNotes
and then dropped by DropSermonNotes, which refuses to drop (failing the deploy, nothing lost) if any old note is
missing from notes, and only touches the church’s own schema (the legacy public copy is left alone). Its down()
recreates the table from each member’s latest sermon note.
Notes Module (src/notes/)
Private notes members write in the member app: sermon notes taken during a service, personal notes and Bible-study
notes. Module key notes (in KNOWN_MODULES, and added to every plan’s features by the root migration
AddNotesToPlans), so ModuleEnabledGuard checks both the church toggle and the plan.
Privacy: a note is only ever returned to the member who wrote it — every query is scoped by member_id and a
note owned by someone else is a 404. No admin route returns note content; GET admin/notes/insights returns totals only.
Content: the editor’s (Tiptap/ProseMirror) JSON document, validated as { type: 'doc' } and capped at 200 KB.
On every save the server derives, never trusting the client:
plain_text— for search and list excerpts;scripture_refs— canonical refs (JHN.3.16,JHN.3.16-18,PSA.23; USFM book codes) fromscripturenodes;commitment— the text under the guided template’s heading withattrs.promptId = 'action'(“One thing I’ll do this week”), up to 200 characters, used for the Monday reminder;word_count— words outside headings, so an untouched template is 0. The streak, the evening reminder and the admin totals only count notes withword_count > 0. Added byAddNoteWordCount, which also back-fills existing notes (with its own frozen copy of the counting rule).
The member app keeps a new note on the phone until something is written in it, so empty notes aren’t created.
Notes for a service: POST notes with serviceSlotId links the note to that slot and its event, titles it after
the event and defaults kind to sermon. A partial unique index (UQ_notes_member_service_slot) allows one note
per member per service, so starting notes again (or two taps at once) returns the existing note instead of a second.
Linking a service: PATCH notes/:id { serviceSlotId } links a note to a service (sets service_slot_id and
event_id); null unlinks. Linking a service that already has another of the member’s notes fails with
409 { code: 'NOTE_SERVICE_TAKEN', noteId }. GET notes/services lists services from the last 35 days
(LINKABLE_SERVICE_DAYS) the member can link to — EVERYONE events plus any they attended — with attended and
the member’s existing noteId for each. GET notes/:id and PATCH notes/:id return the note with
service: { serviceSlotId, serviceName, eventId, eventName, startTime } | null and
sermon: { id, title, speakerName, date } | null.
Linking a sermon (optional, by the member): PATCH notes/:id { sermonId } (null unlinks). It doesn’t change
the note’s kind, and nothing links a sermon automatically — the member app suggests sermons dated the same day as
the note’s service first. Linked notes are listed on the sermon’s page (GET notes?sermonId=).
Edit conflicts: PATCH notes/:id accepts baseUpdatedAt (the updatedAt the client last saw). If the stored
note is newer and the request changes the title or content, it fails with 409 { code: 'NOTE_CONFLICT', note } so
the app can keep both versions (it saves the phone’s copy as a separate note). Pinning skips the check.
Context (GET notes/context): the service slot happening now, or the most recent one today
(start ≤ now + 30 min and end ≥ now − 12 h), preferring the slot the member checked in to, then a live slot. Events
with a non-EVERYONE audience only count if the member has an attendance record. Includes the programme’s first
SPEAKER slot (member name or guest name, and topic), a sermon dated the same local day, and the member’s existing
note for that slot. null when nothing matches.
Streak (GET notes/streak): consecutive weeks (Monday-start, church timezone) with at least one sermon note.
This week counts as pending, so a streak only breaks after a full missed week. Returns { current, best, thisWeek }.
Weeks are cached per member (1 h) and cleared on create/delete. Not shown on any leaderboard.
Most noted (GET notes/top-scriptures?eventId=): up to 5 refs noted by the most members for that event, only
when at least 3 different members noted a ref (TOP_SCRIPTURE_MIN_MEMBERS), so it never points at one person.
Cached 10 min.
Bible version taps (POST notes/scripture-taps): the member app shows KJV and BSB (public domain, bundled in the
app) and opens copyrighted versions on bible.com. It batches taps on those links as { taps: [{ version, count }] };
they are summed per day and version in scripture_link_taps to help a church judge whether a licence is worth it.
Reminders (NoteNudgeScheduler): @Cron('5 * * * *'), Redis lock lock:note-nudges (900 s). Uses
SchedulerGateService.activeTenants() (cached, now including timezone) and only opens a tenant transaction when
that church’s local hour matches, and only if the Notes module is on for the church and its plan:
- 19:00 local: members who attended (
PRESENT,LATE,ATTENDED_ONLINE) an event that ended in the last 14 h and have no note for it getNOTE_EVENING_NUDGE, one push per service, linking to/notes/new?slot=…(idempotency keynote-evening:{eventId}). - Monday 08:00 local: each member’s latest
commitmentfrom the past 8 days, asNOTE_COMMITMENT_REMINDER(shortened to 90 characters), linking to the note (idempotency keynote-commitment:{noteId}).
Both are in the NOTES push category (EMAIL_NOTES_ENABLED, default true; per-church switch in Notification
Settings). Members can opt out themselves with PUT notes/preferences { nudges: false } (members.note_nudges).
Routes (member, JwtAuthGuard + @RequiresModule('notes')): GET notes?page=&limit=&kind=&q=&sermonId=
(paginated summaries, pinned first then newest), GET notes/context, GET notes/streak,
GET notes/top-scriptures?eventId=, POST notes/scripture-taps, GET/PUT notes/preferences, GET notes/services, GET notes/:id,
POST notes, PATCH notes/:id, DELETE notes/:id.
Routes (admin, AdminGuard + @RequiresModule('notes')): GET admin/notes/insights — SERMON_READ; returns
{ notesLast30Days, membersLast30Days, scriptureTaps: [{ version, count }] } (taps over 90 days). Shown as a card
on the admin Sermons page.
YouTube Live Detection (src/integrations/youtube/)
Automated follow-up to the Sermon Module’s manual “Announce Live” trigger — detects when a tenant’s configured
YouTube channel goes live and calls AnnouncementService.createSystemAnnouncement() automatically, no admin click
needed. Per-tenant BYOK, redesigned from an earlier single-global-channel version (docs/MULTI_TENANT_MIGRATION.md
§9 Phase 8b) — every tenant sets their own channel (and optionally their own Data API key) via
PUT /v1/youtube-integration; there is no platform-wide default channel.
Entity — TenantYoutubeIntegration (tenant_youtube_integrations, public schema, not tenant-schema):
| Field | Type | Notes |
|---|---|---|
| id | UUID | PK |
| tenantId | UUID, unique | FK → tenants.id, CASCADE — one integration per tenant |
| channelId | varchar, unique | The tenant’s YouTube channel id |
| apiKeyEncrypted | varchar | null, select: false |
AES-256-GCM (EncryptionService); null means live-detection is a no-op until the tenant sets one — no platform-wide fallback |
| lastAnnouncedVideoId | varchar | null | Idempotency key — prevents double-announcing the same livestream |
| subscriptionExpiresAt | timestamptz | null | Estimated WebSub lease expiry (informational; re-subscription runs daily regardless) |
| isActive | boolean, default true | Toggled via PATCH /v1/youtube-integration — subscribes/unsubscribes the WebSub lease accordingly |
Lives in public, not the tenant schema, on purpose: a WebSub notification arrives from Google’s hub with no
Host header or any other tenant-identifying context — only a channel id inside the Atom XML payload. “Which tenant
owns this channel” has to be answerable before the tenant is known, which per-tenant-schema data structurally can’t
support (same reasoning as TenantCommunicationProviderConfig, docs/MULTI_TENANT_MIGRATION.md §4.12).
channel_id is UNIQUE across the whole table, which is exactly what makes the webhook’s tenant lookup a single
indexed query. Consequently v1/integrations/youtube/callback is excluded from TenantMiddleware (§4.3) — it’s
never resolved against a Host header, and tenant context for it is entered manually (below).
Platform-wide pieces (env vars): YOUTUBE_WEBSUB_CALLBACK_URL and YOUTUBE_WEBSUB_SECRET — the callback URL is
one physical endpoint regardless of how many tenants use it, and the HMAC secret authenticates the hub itself, not
any particular tenant. There is deliberately no platform-wide Data API key: a tenant who hasn’t set their own gets
no live-detection, silently, rather than quietly borrowing shared platform quota (YOUTUBE_API_KEY was removed —
see “No platform-wide API key fallback” below). With YOUTUBE_WEBSUB_CALLBACK_URL/YOUTUBE_WEBSUB_SECRET unset,
YoutubeSubscriptionService.isWebSubConfigured() is false and subscribe()/unsubscribe() are no-ops (logged at
debug level) — nothing breaks for a deployment that hasn’t set these, tenants just fall back to the Sermon Module’s
manual “Announce Live” trigger.
No platform-wide API key fallback: YoutubeLiveDetectionService.handleNotification returns immediately if
apiKeyEncrypted is unset — it never falls back to a shared key, even though any valid Data API key can technically
look up any public channel’s snippet. Each tenant’s own Google API quota is consumed by their own traffic only.
Pro-only, module + plan gated (@RequiresModule('youtube_integration') + @RequiresPlan — same treatment as
Tithe/Giving and Social Media: a BYOK integration gated on business-value grounds, since it costs Discuva nothing
regardless of a tenant’s usage). Gating stops at the config controller — an already-configured integration from
before this module existed keeps running in the background (webhook processing doesn’t re-check plan/module state),
same limitation as every other feature’s pre-existing data surviving a later gate.
Tenant self-service routes (AdminGuard + ModuleEnabledGuard + PlanGuard, tenant-scoped — single resource
per tenant, no :id/:channel param):
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /youtube-integration |
YOUTUBE_INTEGRATION_READ | Returns { channelId, hasOwnApiKey, isActive, subscriptionExpiresAt } | null — never the key itself |
| PUT | /youtube-integration |
YOUTUBE_INTEGRATION_WRITE | Body { channelId, apiKey? } — upserts this tenant’s config, encrypting apiKey if given. Rejects (409) a channelId already owned by a different tenant. Switching channels unsubscribes the old one before subscribing the new one |
| PATCH | /youtube-integration |
YOUTUBE_INTEGRATION_WRITE | Body { isActive } — enable/disable without touching the stored channel/key; subscribes on enable, unsubscribes on disable |
WebSub (PubSubHubbub) flow:
- Whenever a tenant’s integration is created/enabled (
TenantYoutubeIntegrationService.upsert()/setActive(true)),YoutubeSubscriptionService.subscribe(channelId)POSTs a subscribe request to Google’s public hub (https://pubsubhubbub.appspot.com/subscribe) for that channel’s video-feed topic, includinghub.secret(YOUTUBE_WEBSUB_SECRET) — the hub then HMAC-SHA1-signs every notification it sends with that secret. Daily at 2am church time,YoutubeSubscriptionScheduler(distributed-lock guarded the same wayFollowUpScheduleris) callsrenewAllActive(), which re-subscribes everyisActivetenant integration — WebSub leases expire (~5-10 days), so daily renewal keeps every tenant comfortably ahead of expiry regardless of what the hub grants. GET integrations/youtube/callbackhandles the hub’s verification handshake — echoes backhub.challengeverbatim (required by the WebSub spec) forsubscribe/unsubscribemodes,404otherwise.POST integrations/youtube/callbackreceives the actual “video published” notification — an Atom XML body containing both a<yt:videoId>and a<yt:channelId>. Before doing anything else,YoutubeWebhookControllerverifies theX-Hub-Signatureheader (sha1=<hex>) against an HMAC-SHA1 of the raw body computed withYOUTUBE_WEBSUB_SECRET, usingtimingSafeEqual— without this, the public callback URL would accept a forged POST with an arbitrary video/channel id from anyone who discovers it, triggering a fake “we’re live” push to a tenant’s members. A missing/mismatched signature, or no secret configured at all, is dropped silently. Both ids are extracted via small regexes (a full XML parser dependency wasn’t worth adding for two fixed fields). Always acks fast (204, no body) regardless of what happens next —YoutubeLiveDetectionService.handleNotification(videoId, channelId)runs without being awaited by the response.YoutubeLiveDetectionServicetakes a short Redis lock (lock:youtube-notification:{videoId}, 60s TTL) before doing anything else — WebSub hubs routinely redeliver the same notification, and without this, two concurrent deliveries could both pass the idempotency check below before either write lands, double-announcing the same stream. It looks up theTenantYoutubeIntegrationowning the notifiedchannelId(isActive: true) — an unrecognized or inactive channel is dropped silently, no error. It then checks that integration’slastAnnouncedVideoId(the actual idempotency check — the same video can generate multiple WebSub pings across retries/redeliveries). If new, requires the tenant’s own decrypted API key — returns silently if none is configured, no fallback — and calls the YouTube Data API (videos.list?part=snippet) to confirmsnippet.liveBroadcastContent === 'live'— the WebSub ping alone fires for regular uploads too, not just livestreams — and thatsnippet.channelIdmatches the notified channel id, since a forged/mismatched payload could otherwise attribute someone else’s video to this tenant’s announcement. Only then does it look up the owningTenant, manually enter that tenant’s CLS/transaction context (cls.runWith({tenantId, schemaName}, () => txHost.withTransaction(async () => { SET LOCAL search_path; ... }))— the same patternPlatformTenantService.impersonateTenantuses; a webhook has no request-scopedTenantMiddlewarerun to inherit tenant context from, so it has to open one itself), callcreateSystemAnnouncement()inside it, and persist the video id as the newlastAnnouncedVideoIdon the (public-schema) integration row afterward.- All external-call failures (hub POST, Data API call) are caught and logged as warnings, never thrown — a webhook handler that 500s risks the hub retrying or giving up on the subscription entirely.
Mixlr is not automated — it was never tightly coupled to begin with (a per-sermon manual URL field, already tenant-scoped) and no confirmed public webhook/API was found; the manual “Announce Live” trigger remains the only path for Mixlr-only streams. Facebook Live is not a built feature at all.
Routes: GET integrations/youtube/callback — no guard, verified implicitly by the WebSub handshake itself
(same trust model as POST webhooks/billing’s own signature verification). POST integrations/youtube/callback — no
NestJS guard either, but is signature-verified in the controller itself as described above (a Public()-style route
whose actual authentication is the HMAC check, not a bearer token). Both are excluded from TenantMiddleware (§4.3)
since the hub never sends a Host header identifying a tenant.
Env vars: YOUTUBE_WEBSUB_CALLBACK_URL, YOUTUBE_WEBSUB_SECRET — see Environment Variables.
ServiceHeadcount Module
Records and retrieves physical attendance counts for services, broken down by demographic group. All routes are admin-portal only (AdminGuard). Headcount data can be filtered by service slot, date range, or slot name; trends are bucketed by week, month, or quarter.
Entity: ServiceHeadcount — one record per service slot (OneToOne, enforced by a unique constraint on service_slot_id). POST /service-headcount is an upsert: recording again for a slot that already has a headcount edits that row in place instead of creating a sibling, so summing across a service’s sub-services never double-counts.
Computed total: Every response includes a total field (sum of fixed groups + all customGroups values). Not stored in DB.
Event-level summary (GET /service-headcount/event/:eventId/summary): The service-level view for a multi-service Sunday — returns every sub-service (ServiceSlot) under the event ordered by startTime, each with its headcount if recorded (null otherwise), plus an aggregate total summed across whichever sub-services have been recorded so far (recordedCount/slotCount show how many are still outstanding). This is the primary admin-facing view (app/service-headcount’s “By Event” tab) — an admin picks the Event once and records each sub-service’s count inline without leaving the page, and sees the full-service total without adding sub-services up by hand. Reuses the same 5-field-plus-custom-groups form as the flat POST route; no new DTO.
No separate correction endpoint (by design): PATCH /service-headcount/:id existed early on for correcting a record, consumed only by the Records tab’s now-removed “Edit” button (a flat historical list, separate from the “By Event” tab). Once headcount became upsert-on-POST, that PATCH route had no remaining frontend caller — removed entirely (controller route, service method, UpdateServiceHeadcountDto) rather than left as dead, unconsumed admin API surface. Corrections now happen exactly one way: re-recording the same sub-service through the “By Event” tab, which pre-fills the existing values and edits in place.
Trends: GET /service-headcount/trends returns bucketed data. Each bucket is keyed by periodLabel + serviceSlotName so multiple slots on the same Sunday appear as separate series. customGroups’ dynamic per-church keys mean the per-bucket aggregation stays in-memory rather than SQL GROUP BY, but omitting from now defaults to a bounded ~365-day lookback (defaultTrendsFrom()) instead of scanning every headcount record ever logged — an explicit from is always honored as-is.
Email export (POST /service-headcount/export-email): Reuses the same filtered query as the flat GET /service-headcount list (no pagination), builds an .xlsx via the shared ExcelService.buildWorkbook, and queues it as an email attachment via EmailQueueService.queueEmailWithTemplateAndAttachments using the shared report-export template (src/utility/templates/report-export.html, reused by every report’s export endpoint — not headcount-specific). Deliberately one-off: no recurring/scheduled export exists or is planned as part of this feature.
Trends charts (discuva-admin, app/service-headcount/page.tsx): the Trends tab has a Chart/Table toggle (defaults to Chart) consuming the same GET /service-headcount/trends response the table already used — no new backend endpoint, since HeadcountTrendPoint already carries every field the reference dashboard needed (maleAdults/femaleAdults/teenagers/children/serviceSlotName/periodLabel/total). Renders via three new reusable wrapper components (components/charts/bar-chart.tsx, pie-chart.tsx, trend-line-chart.tsx, thin wrappers over the new recharts dependency): a total-attendance trend line across period buckets, a per-service total bar chart, a gender-split pie chart, and a teens-vs-children bar chart — all aggregated client-side from the same trends payload. Fixed a pre-existing bug while wiring this up: the frontend’s Period type allowed "yearly", which isn’t a value the backend’s HeadcountPeriod recognizes — since the controller doesn’t validate/whitelist the query param, selecting “Yearly” silently fell through to quarterly bucketing server-side. Now "weekly" | "monthly" | "quarterly" on both sides.
Routes prefix: /service-headcount
Prayer Roster Module
Manages monthly prayer meeting rosters across one or more named programs. Each program has its own audience type (WORKERS, MEMBERS, or ALL), day configs, schedule rules, and roster entries. Multiple programs can run concurrently (e.g. a worker-only intercessory program alongside an open member prayer program).
Key flows:
- Admin creates programs via
POST /prayer/admin/programs. All subsequent operations pass?programId=to scope to one program. - Admin configures prayer days (
POST /prayer/admin/day-configs?programId=) and frequency rules (POST /prayer/admin/rules?programId=). - Admin generates meetings for a month (
POST /prayer/admin/meetings/generate?programId=). Fixed assignments are auto-applied at generation time. - Admin opens the self-selection window (
POST /prayer/admin/meetings/open-selection?programId=); workers and/or members browse open slots and submit their preference (POST /prayer/select?programId=). Members can only self-select on programs withaudience = MEMBERSorALL. - Admin runs auto-assign (
POST /prayer/admin/roster/auto-assign?programId=&month=&year=) to fill remaining gaps. Auto-assign is available forWORKERSandALLprograms; it clears allAUTO_ASSIGNEDentries first (idempotent), then re-runs the algorithm on clean state. Returns{ assigned, unassignable }. - Admin may manually assign any worker or member via
POST /prayer/admin/roster/manual-assign?programId=with{ meetingId, workerProfileId? | memberId? }. - Admin may remove any non-FIXED
SCHEDULEDentry viaDELETE /prayer/admin/roster/entries/:id. - Exact frequency enforcement (WORKERS/ALL programs): Every worker must be assigned to exactly their required number of slots.
GET /prayer/my-status?programId=returns{ required, selected, canSubmit }. - Concurrent self-selection: The
selfSelectflow runs inside aDataSource.transaction()with apessimistic_writelock on the meeting row to prevent capacity over-booking under concurrent requests. - Reschedule (soft-delete):
PATCH /prayer/admin/roster/entries/:id/reschedulemarks the old entryRESCHEDULED, creates a new entry withrescheduledFromFK, and adjustscurrentCapacityon both meetings. - Admin validates the completed roster (
GET /prayer/admin/roster/validate?programId=&month=&year=). Returns{ valid, issues[] }with per-worker frequency and per-meeting leader checks.
Reminder scheduler (daily at 08:00):
Queries prayer_roster_entries where the meeting date is 2 days or 1 day away and the corresponding flag (reminderTwoDaySent / reminderDaySent) is false. Queues email via UtilityService.sendEmailWithTemplate (fire-and-forget). All flag updates are batched into a single save() call after the loop. Template: prayer-reminder.html.
Routes prefix (admin): /prayer/admin
Routes prefix (worker): /prayer
Entities: prayer_programs, prayer_schedule_configs, prayer_day_configs, prayer_schedule_rules, prayer_fixed_assignments, prayer_meetings, prayer_roster_entries.
Migrations:
1785369600000-CreatePrayerScheduleConfig1785456000000-CreatePrayerDayConfigs1785542400000-CreatePrayerScheduleRules(also seeds 5 default rules)1785628800000-CreatePrayerFixedAssignments1785715200000-CreatePrayerMeetings1785801600000-CreatePrayerRosterEntries1785888000000-AddPrayerIndexes(indexes onreminder_two_day_sent,reminder_day_sent,statuson roster entries;status,selection_statuson meetings)1785974400000-PrayerColumnsToSnakeCase(renames all prayer table columns from camelCase SQL names to snake_case for TypeORM SnakeNamingStrategy compatibility)1786233600000-AddPrayerPrograms(createsprayer_programstable; addsprogram_idFK to day configs, rules, meetings; addsmember_idto roster entries; makesworker_profile_idnullable; backfills with a default “Prayer Program” row)1786320000000-AddFirstTimerConversionFields1786406400000-AddEventThankYouSentAt1786492800000-AddFirstTimerVisits1786579200000-AddFollowUpEnhancements1786665600000-AddAuditLogTargetName1786752000000-AddFollowUpTaskIndexes(indexes onassigned_to_id,status,typeonfollow_up_tasks)1786838400000-AddEmailLogProvider1786924800000-AddPushSubscriptions1787011200000-AddFinanceAccountCode1787097600000-AddPledgeGuestName1787184000000-AddMissingFkIndexes(13 FK indexes across high-traffic tables:attendances.service_slot_id,follow_up_tasks.(member_id, event_id),first_timer_visits.(first_timer_id, event_id),follow_up_notes.task_id,finance_journal_entry_lines.(journal_entry_id, account_id),finance_offerings.fund_id,finance_reconciliation_rows.job_id,tithe_records.(batch_id), compositetithe_records(member_id, payment_date),asset_checkouts.asset_id)1787875200000-CreatePledgeContributions(createsfinance_pledge_contributions:pledge_idFKCASCADE,submitted_by_idFK tomembersRESTRICT,reviewed_byFK toadminsSET NULL,amount,payment_date,reference,statusdefaultPENDING,reviewed_at,finance_note)1788393600000-AddPerformanceIndexes(compositemembers(birth_month, birth_day)for upcoming-birthday lookups;first_timers.created_atfor date-range queries; compositefollow_up_tasks(status, due_date); single-columnstatusindexes ontithe_upload_batches,tithe_unmatched_records,tithe_dispute_records,tithe_payment_proofs;finance_requests.category_id)1792652400000-AddEmailLogSource(adds nullableemail_logs.source—tenantvsplatform_default; existing rows areNULL)1792738800000-AddSocialAccountOAuthTokens(tenant — addssocial_accounts.access_token_encrypted/refresh_token_encrypted/token_expires_at/scope)1792825200000-AddSocialPostMediaPlacementScheduling(tenant — addssocial_post_targets.placement,social_posts.scheduled_for, dropssocial_posts.image_url, createssocial_post_media)1792911600000-AddMemberDirectoryProfiles(tenant — createsmember_directory_profiles, indexed onis_visible)1793217600000-AddSocialPlatformApps(public/control-plane — createssocial_platform_apps, the platform-admin-owned OAuth app catalog)1793304000000-AddMemberDirectoryToProPlan(public/control-plane — appends'member_directory'to theproplan’sfeaturesarray)
Push Notification Module
Delivers Web Push notifications to members and workers via the standard Web Push protocol (VAPID). Backed by the web-push npm package and a dedicated Bull queue (push-notifications).
Key flows:
- Subscribe (once, on first device registration): After
POST /auth/loginregisters the device for the first time (deviceIdtransitions fromnull), the PWA service worker callspushManager.subscribe()and POSTs the result toPOST /v1/notifications/subscribe. This is a one-time setup per device — not called on every login. Calling subscribe again replaces the existing row. - Subscription lifecycle: The subscription persists through logouts. Push notifications are delivered via the browser service worker and fire even when the member is not logged in. The subscription is removed only in these cases:
- Admin device purge (
DELETE /admin/members/:id/device): backend deletes the subscription automatically. The frontend must re-subscribe after the member’s next login on the new device. - OTP device reset (
POST /auth/device-reset/verify): backend deletes the subscription automatically. The frontend must re-subscribe after the member’s next login on the new device. - Explicit opt-out: member calls
DELETE /v1/notifications/subscribe. - Stale subscription: push service returns
410 Goneor404— processor deletes it automatically, no retry. - Key mismatch: push service returns
403saying the subscription was made with a different VAPID key (AppleVapidPkHashMismatch, FCM “do not correspond”) — deleted the same way, since it can never be delivered.
- Admin device purge (
- VAPID key source: clients must subscribe with
GET /notifications/vapid-public-key(the API’s ownVAPID_PUBLIC_KEY), not a separately configured copy. Incident (fixed 2026-09-29): the member app was built with a differentNEXT_PUBLIC_VAPID_PUBLIC_KEYthan the API’s key pair, so every push since launch was rejected by Apple/FCM. The member app now fetches this key and, once per session for opted-in members, replaces any subscription made with a different key and re-registers it. PushNotificationService.dispatchToMemberIds(memberIds, payload)finds subscriptions and enqueues all subscribers’ jobs in a singlequeue.addBulk()call (each still keeps its own stablejobId,push:{memberId}:{idempotencyKey}, for deduplication) rather than onequeue.add()round trip per subscriber — matters most for large-fanout sends (e.g. anALLaudience announcement to thousands of members).PushNotificationService.dispatchToWorkerProfileIds(workerProfileIds, payload)resolves worker profile IDs to member IDs via a single SQL query, then delegates todispatchToMemberIds.PushNotificationProcessorprocesses each job: checks a Redis idempotency key (notif:sent:{memberId}:{idempotencyKey}, 24 h TTL) before sending. On410 Gone,404or a403key mismatch from the push service, the subscription is deleted — no retry. Any other error is re-thrown asPush service responded <status>: <body>for Bull to retry (3 attempts, exponential backoff), so the failed job records the push service’s actual reason.NotificationDispatchService(src/utility/service/notification-dispatch.service.ts, exported from the@Global()UtilityModule) —notifyMember({ category, email?, push? })fires the email and push legs of one notification together. Since the Email/Push switch split, each leg has its own per-church switch: email checksEmailCategorySettingsService.isEnabled(category)here, and the push leg (a catalogue key + vars) is gated byisPushEnabledinsidePushNotificationService. Originally both legs shared one check. Introduced because several call sites (ServiceProgrammeService.notifySlotAssignment,EventReminderService.fireReminder) queued email through that gate but dispatchedPushNotificationService.dispatchToMemberIds()completely unconditionally — an admin disabling a category’s emails silently left push still firing for the same event. Eitheremail/pushoption is independently optional (send email-only, push-only, or both — e.g.fireReminderonly setspushwhenrecipientIdsis non-empty), but the category gate always applies to both uniformly; there’s no per-channel opt-out below the category level. New notification-worthy events should be wired through this rather than callingEmailQueueService/PushNotificationServicedirectly, to get the same-gate guarantee for free.
Trigger points:
| Event | Who is notified |
|---|---|
Selection window opened (openSelectionWindow) |
All active workers |
Auto-assign completes (autoAssign) |
Each newly assigned worker (via notifySlotAssignment, alongside email — see note below) |
Manual assignment (manualAssign) |
The assigned worker or member (via notifySlotAssignment, alongside email — see note below) |
Entry removed (removeEntry) |
The affected worker or member |
Entry rescheduled (reschedule) |
The affected worker or member (via notifySlotAssignment, alongside email — see note below) |
Prayer reminder — 2 days before (PrayerReminderScheduler) |
The assigned worker (alongside email) |
Prayer reminder — day of (PrayerReminderScheduler) |
The assigned worker (alongside email) |
Service/event reminder (EventReminderService) |
All eligible members per audience scope (alongside email — see note below) |
ServiceProgrammeService.notifySlotAssignment (create/auto-assign/manual-assign/reschedule paths) and EventReminderService.fireReminder both route through NotificationDispatchService.notifyMember() (see “Notification Dispatch Service” above) — their push leg is gated by EmailCategorySettingsService.isEnabled(...) the same way their email leg always was, instead of firing unconditionally. The other rows above (selection window, entry removed, prayer reminders) call PushNotificationService directly and remain ungated by category preference — candidates for the same migration in a future pass, per the progressive rollout this was scoped to.
Entity: push_subscriptions — id, member_id (unique FK → members), endpoint, p256dh, auth, created_at, updated_at.
Environment variables required: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT (see §10).
Facility Rental Module
Manages bookable facility slots (halls, rooms, etc.) for members and workers, with tier-based discounts, add-ons, and payment tracking.
Key flows:
- Admin configures facilities (
POST /facility-rental/admin/facilities), pricing tiers (POST /facility-rental/admin/pricing-tiers), add-ons (POST /facility-rental/admin/addons), and calendar blackout blocks (POST /facility-rental/admin/calendar-blocks). - Members/workers browse active facilities and available add-ons, check availability via
GET /facility-rental/facilities/:id/availability?from=&to=, and submit a booking (POST /facility-rental/bookings). - On booking creation, the service resolves the member’s category (LEADER if a
DepartmentLeadrecord exists, WORKER ifrole = WORKER, otherwise MEMBER), looks up the matching pricing tier, and computes a price snapshot. Caution amounts are never discounted. - Pricing formula:
serviceFee = (basePrice + sum(addon prices)) × (1 - discount),grandTotal = serviceFee + sum(addon caution amounts). - On creation, two
RentalPaymentrows are generated — oneSERVICE_FEEand oneCAUTION(skipped if caution total is zero). Both startPENDING. - Overlap check:
createBookingand calendar blocks both use a time-range overlap query (start < newEnd AND end > newStart) against all non-cancelled/rejected bookings. A calendar block also blocks the slot. - Admin confirms (
PATCH .../confirm), rejects (PATCH .../reject), or applies a one-off discount override (PATCH .../discount). Overrides recompute and update theSERVICE_FEEpayment record in place. - Admin marks payments as paid (
PATCH /facility-rental/admin/payments/:id/paid) and marks caution refunded (PATCH /facility-rental/admin/payments/:id/refund) after the booking completes.
Status scheduler (every 10 minutes): RentalStatusScheduler auto-transitions CONFIRMED → IN_PROGRESS when startDateTime ≤ now < endDateTime, and IN_PROGRESS → COMPLETED when endDateTime ≤ now.
Routes prefix (admin): /facility-rental/admin
Routes prefix (member/worker): /facility-rental
Entities: rental_facilities, rental_pricing_tiers, rental_addons, rental_bookings, rental_booking_addons, rental_payments, rental_calendar_blocks.
Migration: 1782303229675-CreateFacilityRental
Permissions: FACILITY_RENTAL_READ, FACILITY_RENTAL_WRITE
Children Church Module
Provides a security-grade check-in/check-out system for children. Key features:
- Children are automatically assigned to an age group and class group based on date of birth. Running
POST /children-church/age-groups/recomputere-evaluates all children against current age-group rules. - Each check-in generates a unique 6-character pickup code. The code is emailed to all registered guardians at check-in time.
- Pickup is verified by code via
GET /children-church/checkin/verify/:codebefore the checkout is submitted. - Any check-in can be flagged with
PATCH /children-church/checkin/:id/flag(e.g. unknown pickup attempt). - Multiple guardians can be registered per child;
isAuthorizedPickupcontrols who may collect. - Admins (not workers) can view live active check-ins across all classes via
GET /children-church/admin/checkin/activeand paginated history viaGET /children-church/admin/checkin/history. GET /children-church/children/:idcarries a transientvisitCount(totalChildCheckInrows for that child, not persisted — same pattern asFirstTimer.visitCount) so a child’s own attendance history reads as a single number, not just a paginatedcheckin-historylist.- A
ChildProfileis not aMemberorFirstTimer— a child has no digital-footprint timeline of their own. Instead,MemberTimelineService.getTimeline()carries achildrenChurchDropOffscount: everyChildCheckInwhere the member is thedroppedOffBy/pickedUpByChildGuardian(found viaChildGuardian.member), across every child they guard. This tracks the guardian’s engagement, not the child’s, and is deliberately kept separate fromvisitCount(which is the member’s own visits) rather than folded into it — conflating “I visited” with “my kid was dropped off” would be misleading.ChildGuardian/ChildCheckInare registered read-only inMemberModule(not by importingChildrenChurchModule, which already importsMemberModule— that would be circular), same pattern asSundaySchoolAttendance/Attendance.
Routes prefix: /children-church
ServiceProgramme Module
Backend replacement for the Firebase-based Service Timer POC. Manages service programme creation, live session control, real-time state broadcast, and post-session analytics.
Architecture:
-
ServiceProgrammeand its slots are authored during the week (DRAFT status). -
When a session starts, the programme transitions to LIVE and a
ServiceSessionis created along withServiceSessionSlotsnapshot rows (one per programme slot). -
Live state (current slot, timer anchor, pause state) is held in Redis. Clients compute the display timer locally using:
elapsed = slotBaseSeconds + (Date.now() - slotStartedAt) / 1000. -
Every state change (advance, rewind, pause, resume, adjust-time, reorder, override) updates Redis and writes durable records to the DB.
-
Live sessions support ad-hoc time adjustment (
adjustTime, ± seconds applied to the running slot’s elapsed time — reuses the same recompute pattern asresume()) and reordering of the not-yet-started (PENDING) tail ofServiceSessionSlotrows via drag-and-drop in the admin UI (reorderLiveSlots— distinct fromServiceProgrammeService.reorderSlots, which only reorders DRAFT-statusServiceProgrammeSlotrows before a session starts). -
effectiveSlotsonGET /service-session/:code/state— a flattened, per-session array ({ id, position, status, type, topic, allocatedMinutes, memberName, guestName, backupMemberId, backupMemberName, backupGuestName, actualSeconds, startedAt, completedAt }, built bywithEffectiveSessionSlotsinutil/slot-display.ts) keyed byServiceSessionSlot.id, with each slot’s DRAFT-template fields (programmeSlot.topic/allocatedMinutes/member/guestName) merged with any live overrides (overriddenTopic,overriddenSpeakerName,overriddenMember,adjustedAllocatedMinutes) already resolved. This is the array every live frontend view (/service-programme/live/:sessionCode,/live/:code/manage,/live/:code/presentation,/live/:code/audience,SessionRunner) reads for its slot list — fixed a bug where those views previously readsession.programme.slots(ServiceProgrammeSlot[], the DRAFT template’s own IDs) and passed those ids straight intoreorderLiveSlots, which validates againstServiceSessionSlotids and therefore always threwBadRequestException('Slot list must contain exactly the upcoming (not-yet-started) slot IDs')— reordering during a live session could never actually succeed. Thebackup*fields always reflect the DRAFT slot’sbackupMember/backupGuestNameas-is — there is no “override the backup” concept, so they stay constant even after the primary speaker has been overridden. -
overrideSlot(POST /service-session/:code/slots/:position/override,RolesGuard+WORKER, andPOST /service-session/:code/pm/slots/:position/override,Public+ShareTokenGuard) lets an operator rename a slot’s topic and/or swap its minister/speaker (by linking aMemberviaoverriddenMemberId, or a free-text guest name viaoverriddenSpeakerName) while the session is live, from either the authenticated Live Session Dashboard or the public Programme Manager link — both call the same service method, withmemberId: nullon the PM path (same pattern asadvance/rewind/etc.). The override is stored on theServiceSessionSlotrow, never mutates the underlying DRAFTServiceProgrammeSlot, and is immediately reflected ineffectiveSlots(and therefore every view listed above) on the next poll. -
Swap to backup — both the Live Session Dashboard and the Programme Manager view render a “Backup: {name} — tap to swap” affordance on any slot that has one (current slot and each upcoming slot in the queue), computed client-side by
backupLabel/backupOverridePayloadinuse-service-session.ts. Clicking it calls the sameoverrideSlotendpoint withoverriddenMemberId(if the backup is a linkedMember) oroverriddenSpeakerName(if it’s a guest) — a one-click fallback for when the primary speaker doesn’t show up, no re-search required. The Presentation and Audience views deliberately do not show backup info — it’s operational/control-surface data, not something the congregation needs to see. -
Adding or editing a slot on an already-created DRAFT programme (
ProgrammeDetailPanel) exposes the same “Add backup speaker” toggle as the creation dashboard’sItemEditorRow(see below) — both flows share the one component, so backup assignment works identically whether you’re building a programme for the first time or coming back to edit it later. -
Each session also gets a
shareToken(random UUID, Redis-only, same TTL/lifecycle as the anchor) generated instart(), powering three public, unauthenticated frontend routes, all readingGET /service-session/:code/state— no new backend endpoints were needed for any of them:/live/:code/presentation(read-only, big-screen display, dark theme, meant to be opened in its own browser tab/window via the “Open Presentation Window” button so it can be dragged to a second monitor/projector and fullscreened with theFshortcut),/live/:code/manage?token=...(full remote control — advance/rewind/pause/resume/adjust-time/reorder/end, share-token gated), and/live/:code/audience(read-only, mobile-first, light theme — current slot + countdown + progress bar, “Up Next”, and the full running order with done/current/upcoming state; intended for members/workers to follow along on their own phone during the service, no share token required since it’s read-only like the presentation view). Admins copy/open these links from the “Presentation Link” / “Presentation Window” / “Programme Manager Link” / “Audience Link” buttons on the service-programme, service-session, and live-session dashboards (GET /service-session/:code/share-links). The link can be invalidated without ending the session viaPOST /service-session/:code/rotate-share-token, which overwrites the Redis key with a freshly generated token — any copy of the old link stops working immediately (this only affects the Programme Manager link; the Presentation and Audience links have no token to rotate). -
Ending a live session always requires an explicit two-step confirmation (“End” → “End?” Yes/No) in every surface that can end one — the authenticated Live Session Dashboard, the
SessionRunnercard, and the public Programme Manager view — so a single stray click can never terminate a session. -
rewindis destructive — it resets the current slot back to PENDING and reopens the previous slot as IN_PROGRESS, unconditionally nulling that previous slot’scompletedAt/actualSeconds(there is no shadow/history column, so a mistaken rewind previously destroyed the recorded actual duration of a finished slot with only an audit-log breadcrumb — no way to recover the numbers). Two mitigations: (1)rewind()now reads both affectedServiceSessionSlotrows before overwriting them and stringifies their priorstatus/startedAt/completedAt/actualSecondsinto theREWIND_SLOTaction-logdetailfield, so the destroyed values are recoverable from the audit log/CSV even though the DB row itself is overwritten; (2) every UI surface that can trigger rewind (Live Session Dashboard,SessionRunner, public Programme Manager view) now requires an explicit Yes/No confirmation before calling it, mirroring the “End Session” pattern. Adjusting the timer (adjustTime, ± seconds) is non-destructive to slot records but still requires the same Yes/No confirmation in the Dashboard and Programme Manager views, since a mis-tap changes the running countdown an operator and congregation are actively watching. -
All four live-session views only overwrite their local
payloadstate when a fetch actually succeeds — a failed fetch (a transient error, a network blip, a brief server restart) leaves the last known-good payload in place rather than nulling it out, so a momentary hiccup is never mistaken for “the session has ended.” Only a fetch that succeeds and returnsanchor.status === 'COMPLETED'(or an initial load that never succeeds at all) shows the ended/not-found state. -
Live updates moved from per-client polling to Socket.IO push (
ServiceSessionGateway, namespace/service-session) — the original design had all four views (Dashboard, Programme Manager, Presentation, Audience) independently pollingGET /service-session/:code/stateevery 1.5–3s. That cost scales withsessions × viewers-per-session, notsessions— and the Audience view has no cap on viewers at all (any number of congregants can open it on their own phone), so a single popular session’s Audience traffic alone could dwarf everything else on a busy Sunday. The gateway and its Redis-backed adapter (RedisIoAdapter,@socket.io/redis-adapter, wired at bootstrap inmain.tsfor horizontal fan-out across multiple app instances) already existed from an earlier phase but had zero consumers and an incomplete broadcast payload; this phase completed the wiring:broadcastState(sessionCode, state: SessionStatePayload)now emits the exact payloadGET /statereturns (anchor,session,effectiveSlots,cautionThresholdRatio— previously onlyanchor/sessionwere sent, missing the slot data every view actually renders from). Every mutating controller method (advance,rewind,pause,resume,adjustTime,reorderLiveSlots,overrideSlot,end, and allpm/*equivalents) re-fetches full state viagetState()and broadcasts it to roomsession:${sessionCode}after the mutation commits.joinSession/leaveSessiongate room membership by validating the session code exists (getState()succeeds) before joining — previously any client could join any room, including nonexistent ones, with zero validation. This is a read-only channel with the same trust model as the public REST routes it replaces (session code = read credential); no write actions happen over the socket.- CORS on the gateway is validated dynamically via
createCorsOriginValidator()(same shared validator as the HTTP API, see §Multi-Tenant Request Scoping’s “CORS origin validation” note) instead of the previous wide-openorigin: '*'. - Frontend:
hooks/use-live-session-socket.tsconnects to the namespace, joins the session’s room, and calls back into each view’ssetPayloadon everysession:stateevent. Each of the four views kept only a much slower (30s) safety-netsetIntervalpoll as a fallback for the rare case a broadcast is missed during a disconnect/reconnect or a backend restart — this is a defense-in-depth measure, not the primary update path anymore. The socket origin is derived fromNEXT_PUBLIC_API_URL’s origin (stripping the versioned/v1path — Socket.IO attaches to the raw HTTP server, not the REST prefix). - The per-IP
@Throttle({ limit: 300, ttl: 60_000 })override onGET :code/state/GET :code/slots/:positionis left in place for the initial load and safety-net poll, but is no longer the thing standing between this module and a real capacity problem — it never bounded aggregate load across many distinct viewer IPs in the first place. handleConnectionadds a separate, additive, authenticated tenant-room join — used only for a globalactiveSessions:changedbroadcast (which sessions are currently LIVE for the tenant), distinct from the anonymous per-session-code rooms above. A client presenting a valid JWT athandshake.auth.token(verified the same wayTenantMiddlewareverifies a tenant claim — access secret first, then refresh secret) is joined totenant:${tenantId}; a missing or invalid token is a silent no-op, never a rejected connection, so the anonymous audience/presentation/manage flows are completely unaffected.ServiceSessionController.start/startEvent/end/pmEnd— the four actions that change whether any session is live — each callgateway.broadcastActiveSessionsChanged(tenantId, sessions)(tenant id read from the request’s CLS store, same asTenantMiddlewarewrites it) after their existingbroadcastStatecall. Frontend:hooks/use-active-sessions-socket.tsconnects with the current access token fromtokenStore;useActiveSessions()(mounted globally viaLiveSessionPillinShell, so it runs on almost every authenticated page) consumes it in place of its previous 20-60s unconditional poll, keeping only a 5-minute safety-net poll as a fallback.
-
The presentation view’s countdown has three visual states: normal (white) → caution (“Wrapping Up”, amber, pulsing) once remaining time drops to
SERVICE_SLOT_CAUTION_THRESHOLD_RATIO(env, default0.25, i.e. the last 25% of the slot’s allocated time) → overtime (“Time’s Up”, red, pulsing) once elapsed exceeds the allocation, after which the display counts up (+MM:SS). The ratio is resolved server-side and returned ascautionThresholdRatioonGET /service-session/:code/state, so the frontend has a single source of truth rather than duplicating the value in its own env config. SeeSlotTimerDisplay(frontend). The presentation page also supports a keyboard shortcut (F) to toggle browser fullscreen via the Fullscreen API. -
When a
ServiceProgrammeSlotis assigned a member (viaaddSlotorupdateSlot’smemberId),notifySlotAssignment()fires both channels viaNotificationDispatchService.notifyMember()(see “Notification Dispatch Service” below) — email requires the member to have an address on file, push doesn’t (a member may have one channel but not the other, and neither blocks the other), but both are gated together by the sameEmailCategorySettingsService.isEnabled(EmailCategory.SERVICE_PROGRAMME_ASSIGNMENT)check:- Email (template:
service-slot-assigned) if the member has an email on file, with a generated.icscalendar invite attached when the underlyingServiceSlothas both astartTimeandendTime. The template body includes the formatted service date ({{ serviceDate }}, e.g. “Sunday, 19 July 2026”) and time range ({{ serviceTime }}, e.g. “8:00 AM – 10:00 AM”) as plain text in the “Your slot” attributes table — not just carried in the.icsattachment, so the schedule is readable even without a calendar client. Both fields are computed once viafmtAssignmentDate/fmtAssignmentTimeand reused for the push body below. - Push notification to the assigned member.
idempotencyKey: service-slot-assigned:${slot.id}:${member.id}keys it per slot-and-person so a primary/backup reassignment on the same slot doesn’t dedupe against each other. Body includes the slot type, service name, and date/time when available (e.g. “Speaker — Sunday Service — First Service on Sunday, 19 July 2026 at 8:00 AM – 10:00 AM”); links to/eventsin the member app.
Before
NotificationDispatchServiceexisted, push was dispatched unconditionally — an admin disabling this category’s emails silently left push still firing for the same event. Fixed by routing both legs through the shared category gate instead of only checking it on the email leg.Guests (
guestName, no member record) never reach this method — nothing to email or push. Re-editing a slot without changing its assigned member does not re-send either notification. The response fromaddSlot/updateSlotmay include a non-blockingconflictWarningstring when the assigned member already has another slot (in a different programme) whose service time overlaps this one — surfaced in the admin UI but never prevents the save. - Email (template:
-
Assigning a backup member (
backupMemberId, via the same two endpoints) triggers the identical email + push pair for the backup, withisBackup: truein the template data — the template ({{#if isBackup}}) swaps the heading/body copy to make clear they’re the backup, not the primary, and the subject/push title reads “You’re the Backup for: …” instead of “You’ve Been Added to the Programme: …”. Same at-most-once-per-change rule as the primary: re-editing a slot without changing the backup does not re-send. -
POST /service-programme(create) is fully batched, not one round trip per programme/slot. Given N programmes (each with its own set of slots — e.g. creating First Service and Second Service’s whole order-of-service in one request), the previous implementation looped per programme (programmeRepo.saveonce each) and, within that, per slot (amemberRepo.findOnefor the assignee, another for the backup, thenslotRepo.save) — a 2-programme, 15-slot-each request was 60+ sequential DB round trips. It now: resolves every referencedmemberId/backupMemberIdacross every programme’s slots in onememberRepo.find({id: In(...)}), bulk-inserts all programmes in oneprogrammeRepo.save(array), bulk-inserts all slots in oneslotRepo.save(array), and reloads all created programmes for the response in oneprogrammeRepo.find({id: In(...)})instead of an N-timesfindOne. No change to the request/response shape. One intentional side-effect of the batching: the per-slotconflictWarningcomputation (findMemberConflictWarning) is no longer run duringcreate()— its result was already discarded here even before this change (onlyaddSlot/updateSlot’s single-slot paths surface it), so skipping the computation removes wasted queries without changing any observable behavior. -
create()sends one consolidated notification per member instead of one per slot. When the same person is assigned (as primary or backup) to multiple parts across the programmes in a singlecreate()call — e.g. a worship leader rostered for both First and Second Service in the same request — the old per-slotnotifySlotAssignment()loop sent one separate email and one separate push per assignment, so a member on 3 slots got 3 emails.create()now builds anassignmentItems[]list across all programmes/slots being created and hands it to a new privatenotifyBulkSlotAssignments(), which groups by member and, per member: still sends one push per assignment (unchanged — pushes are terse enough that batching them into one buys nothing and would lose the per-assignmentidempotencyKey: service-slot-assigned:${slot.id}:${member.id}deduplication), but sends one email (template:service-programme-assignments, new file) covering every part, with one.icsattachment per assignment. Each part in the email numbers itselfpartNumber(precomputed server-side asindex + 1when building the template data — Handlebars’ built-in@indexis 0-based and this codebase registers no custom helpers, so a 1-based@index1doesn’t exist and can’t be relied on in the template); a backup assignment renders as “Backup for: {slot}” instead of a numbered part. Subject is “You’ve Been Added to the Programme: {slot}” for a single assignment, or “You’ve Been Added to {N} Parts of the Programme” for multiple. Still gated byEmailCategorySettingsService.isEnabled(EmailCategory.SERVICE_PROGRAMME_ASSIGNMENT)and still skips the email leg for a member with no address on file, same as the single-assignment path. This only applies tocreate()—addSlot/updateSlot(adding or editing one slot on an already-created DRAFT programme) still call the originalnotifySlotAssignment()and send one email per call, since batching those would require deferring/debouncing a notification across separate, independent requests rather than grouping work already known to be one request — out of scope for this pass. -
ServiceProgrammeReminderSchedulerruns daily at 09:00 (@Cron('0 9 * * *'), guarded by a Redis lock so only one instance runs it) and emails a reminder (template:service-slot-reminder, same.icsattachment logic as the assignment email) to every assigned member whoseServiceProgrammeSlot.reminderSentAtis still null and whose programme is DRAFT with aServiceSlot.startTime24–48 hours away.reminderSentAtis stamped immediately after queuing to guarantee at-most-once delivery even if the cron overlaps a slow run. -
ProgrammeAutoStartScheduler(opt-in, off by default) starts a service’s session on its own, without a worker tapping “Start” — for churches that want the live session to begin the moment the scheduled time arrives rather than relying on someone remembering to start it. Gated per-EventConfigbyautoStartSession(boolean, defaultfalse; no per-slot override — a config-wide default was judged sufficient rather than adding a second admin UI surface preemptively). Runs every 5 minutes (@Cron('*/5 * * * *'), same Redis-lock +forEachActiveTenantshape as the reminder scheduler above); per tenant, queriesServiceProgrammes that areDRAFT, whoseserviceSlot.config.autoStartSessionis true, and whoseserviceSlot.startTimefalls between start-of-day (church-local, viaDateService.startOfDay()) andnow— that lower bound is deliberate, since an unbounded query would resurrect every ever-forgotten DRAFT programme, not just today’s. This was previously a tight 10-minute trailing window ([now - 10min, now]), which was itself a bug: a later slot in a multi-slot event (e.g. Second Service) only becomes startable once the prior slot’s session is manually ended, which a front-desk worker might do well after the slot’s own nominalstartTime— a 10-minute window meant that by the time the prior session was finally ended, the next slot’sstartTimehad already scrolled out of the window, permanently stranding it inDRAFTfor the rest of the day. Anchoring to start-of-day instead keeps every one of today’s due slots eligible all day, while still refusing to resurrect aDRAFTprogramme genuinely forgotten from a previous day. -
Each due event starts the specific due programme, not “whatever’s earliest for the event” — the batch is grouped by
serviceSlot.event.id, and for an event with more than one due slot in the same run, only the earliest-due one’sprogrammeIdis passed toServiceSessionService.startEvent(eventId, null, programmeId); a later due slot for the same event is picked up on the run after this one ends, same as the manual “Start” button’s own one-slot-at-a-time behavior.startEvent’s third, optionalprogrammeIdparameter is what makes this possible — when given, it’s started directly; when omitted (every other caller, including discuva-admin’s “Start” button), it falls back to the original “earliest startable DRAFT programme for this event” lookup, unchanged. This is a fix, not the original design: previously the scheduler passed onlyeventId, always falling into that “earliest DRAFT for the event” fallback — which doesn’t know or care whether that earliest programme was itself due or even auto-start-eligible at all. A church that configures a multi-service Sunday’s order of service for both services in advance, withautoStartSessionenabled on only the later one (the earlier one is always started manually, and may still legitimately be sittingDRAFTbecause nobody has gotten to it), would have the scheduler silently auto-start the earlier, not-due, not-auto-start-eligible programme instead of the one that was actually due — auto-start effectively never working for that config.startEvent’s existing guard against a second concurrently-LIVE session for the same event is unchanged either way (aConflictExceptionhere is an expected, silent skip — e.g. a second due slot in the same batch that the first call already handled — not logged as a failure). One event’s unexpected failure (e.g. a programme somehow ending up with zero slots) is logged and skipped without blocking the rest of the tenant’s batch. -
ServiceSessionService.start/.startEventacceptmemberId: string | null—nullmeans no human actor, used byProgrammeAutoStartSchedulerabove.assertCanControlSessionis skipped entirely whenmemberIdis null (if (memberId) await this.assertCanControlSession(memberId);, the same optional-actor pattern several sibling methods in this service already used for their own call sites), and the resultingSESSION_STARTEDServiceActionEntrygetsperformedByMember: nullwithactorLabel: 'Auto-started'instead of attributing it to a member — surfaced in the action log the same way a named Programme Manager grant’sactorLabelalready is. The two controller routes below are unaffected — they always pass the calling admin’s realreq.user.id. -
GET /service-session/:code/action-log/csvstreams the fullServiceActionEntryaudit trail for a session as a CSV download (Timestamp, Actor Role, Actor, Action, Detail) for admins who need an offline record beyond the in-app log;GET /service-session/:code/action-logreturns the 10 most recent entries as JSON for the dashboard’s in-app activity feed.GET /service-session/:code/report/pdf(session report PDF),GET /service-session/event/:eventId/report/pdf(full event report), andGET /service-session/event/:eventId/report/summary-pdf(event summary) all existed on the backend with no frontend consumer for a while — all three are now wired: the Live Session Dashboard’s “Share & Access” card has a “Session Report (PDF)” button (next to “Audit Log (CSV)”) for the first; the Programmes list’s per-event header has “Full Report”/“Session Report”/“Summary” download buttons for the other two (see Service Programme Module notes below for their distinct availability gating). -
Session report fixes —
buildSessionReport()'stotalPauseDurationSecondsonly ever summed pause entries that had aresumedAt(ServicePauseEntry.resumedAt: Date | null); a session ended while still paused left that final pause entry withresumedAt: nullforever, silently dropping its entire duration from the total (and showing “ongoing” in the pause log indefinitely).end()now closes any still-open pause entry (resumedAt IS NULL, same query used byadvance()/resume()) inside its existing transaction before finalizing the session, so the total and the pause log are always accurate once a session ends. Separately, the session-report PDF’s per-slot table (PdfService.drawSessionReport/drawFullEventReport) dropped its “Type” column and split the previous combined “Topic / Speaker” column (joined with·) into two distinct “Topic” and “Speaker” columns — removing the Type column on its own would have made any slot with no topic set indistinguishable from another (SessionSlotReport.topicis nullable), so the newPdfService.slotTopicLabel()falls back to a human-readable type label (ServiceSlotTypeLabels[type], e.g. “Praise & Worship”) whenever a slot has no topic, rather than a bare “—”;drawEventSummaryReport’s already-separate Topic/Speaker columns anddrawOrderOfServiceTable’s topic column were both updated to use the same shared helper for consistency. The single-session PDF also gained a small Analysis section — a handful of narrative, presentation-only insights derived from data already onSessionReport(on-time/over/under completion counts and combined variance, skipped-slot count, the single biggest overrun slot, and a pause summary with the most common reason) — rendered between the Pause Log and the closing time-summary band. These insights are computed inPdfServiceat render time and are deliberately not added to theSessionReportJSON contract or theGET /service-session/:code/reportresponse. The Pause Log table (in bothdrawSessionReportanddrawFullEventReport) also stopped rendering a bareSlot ${p.slotPosition + 1}—ServicePauseEntryonly ever stored the slot’s numeric position with no name, so the report showed unexplained entries like “Slot 3” with nothing tying it back to the actual slot.PdfService.pauseSlotLabel()now resolves that position back to the matchingSessionSlotReportand reusesslotTopicLabel(), so the Pause Log shows the same human-readable topic/type label as the Slots table above it. -
GET /service-session/:code/pm/report/pdf— the same session report PDF, now also reachable from the public Programme Manager link (ShareTokenGuard+NamedAccessGuard, controller delegates to a shared privatesendSessionReportPdfhelper alongside the admin route to avoid duplicating the response-header logic).getReportPdf/getFormattedReporthave no session-status precondition — this works whether the session is still LIVE or already COMPLETED — but the frontend surfaces it specifically on the manage page’s “Session Ended” screen, since that’s the point a PM user actually wants it. This required reordering/live/:code/manage’s early-return checks: the name+PIN sign-in gate now runs before the “Session Ended” check (previously the reverse — anyone with just the raw link could see the ended-session message with no PIN at all, and a report-download button placed there would have failed silently for anyone who hadn’t signed in). Now reaching the ended-session screen guarantees agrantTokenis already in hand. -
Admin frontend information architecture: the
GET /service-programmelist groups programmes under their parent event (using theevent/serviceSlotDetailfields above) instead of rendering every service slot as an unrelated row, so multi-slot events (e.g. First/Second Service on the same Sunday) visibly belong together. A persistent “Live” pill in the admin top bar (useActiveSessions, pollingGET /service-session/active) is reachable from any page and deep-links straight into a dedicated full-width Live Session Dashboard at/service-programme/live/:sessionCode— replacing the old cramped side-panel controls, which now show only a status summary with a link to the dashboard. The poll interval backs off adaptively: 20s while at least one session is LIVE, 60s while idle (the common case, since most of the time nothing is live) — this cut the steady-state request volume from this always-mounted, every-page component by 3x without slowing detection of a session actually starting/ending. -
All four live-session frontend surfaces (Live Session Dashboard, Programme Manager, Presentation, Audience) explicitly check
anchor.status === 'COMPLETED'and render a dedicated “Session Ended” screen — previously they only checked whether the anchor/payload existed at all, so once a session legitimately ended,currentSlot(looked up byanchor.currentSlotPosition) still resolved fine and every view kept showing the ordinary live stage with no active slot to display, reading as a stuck/broken UI rather than a finished session. -
Starting a session late (after its
ServiceSlot.startTimehas passed) has never been restricted —ServiceSessionService.start()has no time-window check, so any DRAFT programme with slots can be started at any time viaPOST /service-session/programme/:programmeId/start. -
A concurrent double-start of the same programme now surfaces the same friendly
ConflictExceptionas the “still live” pre-check, instead of a raw driver error.assertProgrammeIsDrafttakes no row lock, so two near-simultaneousstart()calls for the same programme (a double-tap on the button, or the auto-start scheduler racing a manual start) can both pass it; the DB’sservice_sessions_programme_id_keyunique constraint is the real backstop that prevents an actual duplicateLIVEsession, but the loser previously got an unhandledQueryFailedErrorstraight from the driver. Themanager.save(ServiceSession, ...)call is now wrapped the same wayAttendanceService.checkin()already handles its own unique-constraint race: catch, checkdriverError.code === '23505', throwConflictException('A service session for this programme was just started — refresh and try again'); anything else rethrows unchanged. -
A DRAFT programme that was created but never started can be permanently deleted via the pre-existing
DELETE /service-programme/:id(blocked once a programme leaves DRAFT). The admin Programmes list now surfaces this directly on each DRAFT row (a small trash icon, previously only reachable from inside the detail panel) so an abandoned programme can be removed from the “ready to start” list without opening it first. -
When the session ends, remaining PENDING slots are marked SKIPPED, the programme status moves to COMPLETED, and if
saveAsTemplate = truethe programme is auto-saved as aServiceProgrammeTemplate. A session-report email is fire-and-forget dispatched to all active Admin department workers via Bull queue (template:service-session-report). -
Indexes (migration
AddServiceProgrammeQueryIndexes):service_sessions(status)backs the frequently-polledgetActiveSessions()(global Live pill, every 20s from every open admin tab);service_programme_slots(member_id)backs the synchronous double-booking conflict check run on every slot assignment;service_programmes(status)backs the daily reminder scheduler’s DRAFT filter; a partial index onservice_programme_slots(reminder_sent_at) WHERE reminder_sent_at IS NULLmatches that scheduler’s exact predicate and stays small regardless of table growth.service_slots(start_time)/(end_time)(pre-existing) already cover the conflict check’s time-overlap comparison and the reminder scheduler’s 24–48h window. -
Create Programme dashboard — the admin “New Programme” flow (
CreateProgrammeDashboardinapp/service-programme/page.tsx) replaced a plain “pick one slot, create an empty draft, add items one at a time afterward” modal. The entry point is an Event picker, not a slot picker — service slots are only ever a sub-part of an event, so making the admin pick one arbitrary slot just to “unlock” the rest was the wrong mental model. The dropdown lists distinct events (deduped from the slot list client-side, dated by their earliest slot’s start time, events where every slot already has a programme excluded), and picking one loads all of that event’s slots into a full-width dashboard: a left-hand list of the event’s services (each independently checked on/off, with its own item count/duration, the first not-yet-programmed one auto-selected), and a right-hand editor for whichever service is selected — each service’s order-of-service is a genuinely separate list (no shared/master list, per explicit product direction), built with real HTML5 drag-and-drop reordering (matching the pattern already used for reordering an existing DRAFT programme’s slots) plus the existing move-up/down buttons for accessibility. Submitting maps each checked service to oneprogrammes[]entry in thePOST /service-programmecall above.- Item entry is inline, not modal-based (
ItemEditorRow) — the initial version reused the old full-screenAddSlotModal/EditSlotModal(originally built for adding/editing a single slot on an already-created programme) for this dashboard too, which turned out to be too many clicks per item for building a whole order-of-service in one sitting. It was replaced with an always-visible “quick add” row at the end of each service’s item list — type, topic, duration, and a single merged speaker field (seeSpeakerInputbelow) editable directly in place; pressing Enter or the check button appends the item and immediately resets the row for the next one, with no modal open/close cycle. Clicking an existing item turns that row into the same inline editor (pre-filled, Save/Cancel) instead of reopening a modal. ProgrammeDetailPanel(editing an already-created DRAFT programme) now uses the sameItemEditorRowinstead ofAddSlotModal/EditSlotModal, which have been deleted — editing a programme days after creating it now has the exact same inline, no-modal feel as building it the first time, instead of two different UIs for the same data depending on when you touch it.slotToEditorValue()converts the live APIServiceProgrammeSlotshape (member/backupMemberas nested{id, firstname, lastname}objects) into the sharedItemEditorValuethe row edits; committing calls the realaddSlot/updateSlotendpoints directly (no local draft array — each commit is its own API round trip, unlike the creation dashboard which batches everything into onePOST /service-programmecall). Topic and speaker quick-pick suggestions are drawn from the programme’s own other slots rather than the whole event, since this panel only ever has one programme’s slots in scope.SpeakerInputmerges the old Member/Guest toggle into one field: typing is treated as a guest name by default, and picking a live-search match upgrades it to a member — removing the extra “which kind of person” click before you could even start typing. A backup speaker toggle (“+ Add backup speaker”) is available on each row in this dashboard, using the same mergedSpeakerInput.- Item type is inferred, not chosen — the old type
<select>duplicated the title (“Praise & Worship” twice).ItemEditorRownow has one title field (datalist: titles already used in the event, thenCOMMON_PROGRAMME_ITEMS) and a small icon button (SlotTypePicker).inferSlotType(title)(components/service-programme/slot-type-config.tsx, keyword match, first hit wins: break → dedication → offering → announcement → prayer → worship → speaker) setstypeas you type, falling back toOTHER; picking a type on the icon setstypeLocked, and an existing item whose stored type differs from what its title implies opens locked, so editing never silently re-categorises it.typeis still stored and sent exactly as before — no API change — and still drives the icon/colour in both apps, the PDF label fallback and history’s “by type” breakdown. Slot cards show the badge icon-only when there is a title, so the label isn’t repeated. - Topic and speaker fields autocomplete/suggest from names already used anywhere else in the same event (
topicSuggestions/memberSuggestions, derived client-side from all services’ in-progress items — not persisted, not an API concern), so a repeated item (e.g. “Praise & Worship”, the same worship leader) doesn’t have to be retyped per service. - “Copy from…” in the active service’s header lets you duplicate another already-configured service’s full item list (including backups) into the current one in one action — the order of service is usually similar across a multi-service Sunday even though the ministers differ, so this is duplicate-then-edit rather than a shared/master list (each service’s items stay fully independent once copied; editing one afterward never affects the other). Prompts for confirmation only if the target service already has items, since that copy would overwrite them.
- “Apply template…” sits next to “Copy from…” in the same header — applying a saved
ServiceProgrammeTemplate(previously only usable viaapplyTemplate()on an already-created programme, a second trip after creation) now populates the active service’s local draft list directly at creation time, client-side, the same way “Copy from…” does (templateSlotToDraftItem()converts the template’sServiceProgrammeSlot[]into the localDraftItem[]shape). Same overwrite-confirmation rule as “Copy from…”.applyTemplate()/ApplyTemplateModalare unchanged and still available on an already-created programme viaProgrammeDetailPanel’s “Template” button — this is an additional, earlier entry point, not a replacement. DraftItemRow’s secondary line (speaker/duration) uses each slot type’scfg.textcolour (e.g.text-amber-800for Speaker) instead of a flat grey — that per-type colour token existed inSLOT_TYPE_CONFIGalready but was unused; pairing it with the matchingcfg.bgtint (e.g.bg-amber-50) gives correct, type-appropriate contrast instead of one grey that read as low-contrast against every row colour.- “My Upcoming Assignments” — previously a member/worker’s only signal that they were scheduled was the one-off
service-slot-assignedemail; there was no way to look it up later.GET /service-programme/my-assignments(getMyUpcomingAssignments()inServiceProgrammeService) fixes this on the read side: any authenticated member/worker can pull their own upcoming slots — as primary or backup — across every not-yet-completed programme. This is admin-portal-agnostic (JwtAuthGuardonly, no admin permission), consumed by the member-facing app (discuva-member, a separate Next.js PWA from the admin portaldiscuva-admin) rather than the admin dashboard —hooks/use-my-assignments.tsthere polls it every 30s (usePollingEffect, same visibility-aware pause/catch-up behavioruseMyLiveStatususes) andcomponents/layout/home.tsxrenders a horizontally-scrolling “My Upcoming Assignments” card row on the member home screen (dark cards matching the existing hero’s palette), shown only when the member actually has something coming up. The hook originally fetched once on mount only — a member who opened Home before their assigned service’s session wentLIVEand simply left the app open never saw the card flip into its tappable/countdown-eligible state (theisLivecheck depends onprogrammeStatus/sessionCodefrom this same response), since nothing ever re-fetched it; polling fixes that. - Real-time “my slot” view + personal service history — two more member-facing additions alongside “My Upcoming Assignments” in
discuva-member: (1) once a member’s upcoming assignment’s programme goes LIVE, its card becomes tappable (pulsing “Live” badge) and links to/my-assignment/:sessionCode, a page backed byGET /service-session/:sessionCode/my-status(getMyLiveStatus()) — shows a live countdown to their turn, an “you’re up now” banner once it arrives, an “your part is complete” state afterward, and the full running order with their own row highlighted; the countdown ticks locally client-side between 8s polls using the samefetchedAt+ elapsed-time technique the admin Live Session Dashboard already uses, rather than polling more aggressively. (2)/service-history(hooks/use-my-service-history.ts→GET /service-session/my-history) — a paginated list of the member’s own completed slots with total time served and a per-slot-type breakdown, linked from Profile’s general Explore section (not worker-gated —ServiceProgrammeSlot.memberhas no role restriction, so a plain member assigned a slot has just as much reason to see this as a worker; the frontend tile-visibility gate was the only thing that had ever restricted it,getMyServiceHistoryitself never did). Both reuse existing server-side logic rather than introducing new authorization concepts:getMyLiveStatusnever exposes other members’ identities (only role/position/timing derived values), andgetMyServiceHistory’s effective-speaker crediting rule is identical togetAnalytics’smemberIdfilter, so the two can never disagree about who gets credit for a slot.getMyServiceHistoryalso includes a LIVE session’s already-completed slots, not just fully-COMPLETED sessions — a slot’s own status flips toCOMPLETED(withactualSecondsset) the moment it’s advanced past, well before the session as a whole is ended, so history reflects that immediately rather than waiting for someone to end the whole session; the per-slotCOMPLETEDfilter this relies on also incidentally excludesSKIPPEDslots (whichend()produces for anything stillPENDINGwhen a session is ended early) from ever surfacing as if they’d been performed. - General order-of-service view + Front Desk session control — two more member-facing additions in
discuva-member, both reusingGET /service-programme/upcoming(getUpcomingForMembers()inServiceProgrammeService— the soonest programme that’sLIVE, orDRAFTwith a still-futureserviceSlot.startTime; aLIVEprogramme always qualifies regardless of its original scheduled time, so a service running long doesn’t vanish from view just because the clock passed its start. Returnsnull, never a 404, when nothing qualifies — “no service scheduled right now” is a normal state. Slots map tospeakerName/backupSpeakerNamestrings only, never the rawMemberrow, since — unlikemy-assignments/adminfindOne— this is returned to any authenticated member, not just whoever has a slot in it): (1)/order-of-service— a read-only, printed-programme-style listing of the whole lineup (every member, not just those with a slot in it), the answer to “what’s the order of service this week.” (2)/front-desk-session, gated behind the sameFRONT_DESK_OPERATIONScapability tile as “Check Someone In” — a scoped-down live-control surface (start / back / next / pause+reason / resume / end, polling the same publicGET /service-session/:code/statethe audience/PM display views use) built for a front-desk worker running the room week to week, not the full producer toolkit (reordering live slots, arbitrary time adjustment, and overriding a slot’s speaker stay reachable only via the admin Live Session Dashboard or the public Programme Manager link). - Department slots (whole teams) — a slot (and separately its backup) can be given to a
Departmentinstead of a member or guest:service_programme_slots.department_id/backup_department_id(SET NULL, partial indexes), DTOdepartmentId/backupDepartmentId. A department can’t share a slot with a person (400); on update, settingdepartmentIdclears the member/guest and setting a member/guest clears the department. Membership = active worker profiles whose primary or secondary department it is (DepartmentAccessService.findMemberIdsInDepartment); the contact is the department’s HOD (findHeadOfDepartment). Notifications: one push (SERVICE_SLOT_TEAM_ASSIGNED) to every member plus the HOD, and theservice-slot-assignedemail to the HOD only — so a 40-person choir never gets 40 emails. Reminders: the day-before scheduler now goes throughNotificationDispatchServiceand adds aSERVICE_SLOT_REMINDERpush for individual slots; for department slots it pushes the whole department and emails the HOD (with the.ics). Member reads:my-assignmentsmatches department slots viafindDepartmentIdsForMemberand returnsasDepartment;:sessionCode/my-statusmatches through the department (only while nobody has been put in its place on the day) and returnsasDepartment;my-historyincludes the department’s completed slots (asDepartmenton each entry) and abyDepartmentrollup (count,totalActualSeconds,totalOverrunSeconds) — the team’s performance. Display:speakerName/EffectiveSessionSlot.departmentName/ PDFs / session report fall back to the department name. Templates keepdepartmentIdper slot (people are still not kept) and applying one notifies the department. - Analytics tab — a third tab alongside Programmes/Templates (
AnalyticsTabinapp/service-programme/page.tsx) surfacesGET /service-session/analytics, which existed on the backend fully built but had no frontend caller before this. Filterable by date range and service slot name; renders summary cards (completed sessions, avg completion rate, total overrun slots, total pause time — all derived client-side from thesessionsarray) plus three tables: per-slot-type stats (avg actual vs. allocated time, overrun counts), top speakers (by total/avg time on the mic), and recent completed sessions. Gives an admin running several services a week visibility into load-balancing and pacing without opening individual session reports one at a time. Defaults to a bounded ~180-day lookback (ServiceSessionService.defaultAnalyticsFrom()) whenfromis omitted, instead of the 6-way-joined query scanning every COMPLETED session ever recorded — the tab’s ownfrom/toinputs are pre-populated with this same 180-day window on first load so the shown range is never silently narrower than what the UI displays; an explicitfrom(however old) is always honored as-is.- Fixed: the “Service Slot Name” (and date-range) filters silently did nothing —
load()was wrapped inuseCallback(..., [fetchAnalytics]), missingfrom/to/serviceSlotNamefrom its dependency array, so the memoized closure always read the empty strings captured on first render regardless of what was typed. Fixed by including them in the deps; the initial-mount fetch now runs from a plainuseEffect(() => { load(); }, [])instead of depending onloaditself, so typing a filter doesn’t trigger a fetch on every keystroke — only clicking “Load” (onClick={load}) does, now with the current input values. - Fixed (backend): even once the frontend closure bug was fixed, the filter still only matched a session’s sub-service label (
serviceSlot.name, e.g. “First Service”) — typing the service’s actual name (the parent Event’s name, e.g. “Sunday Service”) matched nothing, sinceeventwas never joined into the analytics query at all.fetchAnalytics()now joinsserviceSlot.eventand matches(serviceSlot.name ILIKE :name OR event.name ILIKE :name), so either name works. Frontend field relabelled “Service Slot Name” → “Service Name” to match. - The “Service Name” field suggests as you type via
ServiceNameFilterInput, matchingSearchableSelect’s visual pattern (the same one the service-headcount page’s slot pickers use) rather than a native<datalist>— a search-icon input, a dropdown of matches each showing its date as a grey sublabel ({name} — {date}, since the same name recurs across many dates and there’d be no way to tell occurrences apart otherwise), and once a suggestion is clicked, a chip ({name} — {date}+ a clear button) replaces the input, exactly like a selectedSearchableSelectoption. The datalist was tried first but browser-native datalist rendering/filtering is inconsistent enough that it read as broken to users. One deliberate difference fromSearchableSelect: typing without clicking a suggestion still updates the filter value (clearing any previously-selected chip back to free-text mode) rather than requiring an exact pick, since the backend does a partial/ILIKE match onserviceSlotNameand the field needs to stay usable for names or occurrences that aren’t in the (client-side, non-exhaustive) suggestion list. The filtering itself is a local substring match — no API round-trip, unlike the Minister/Speaker filter which searches live. - Fixed: suggestions were sourced from the hook’s
fetchServiceSlots()— built for the “Create Programme” picker, so it deliberately filters to future events only and excludes any slot that already has a programme. Analytics needs the opposite (names of past/completed sessions), and since a slot only shows up in analytics once its programme has actually run, that filter combination excluded essentially every real name, leaving the suggestion list empty regardless of the input component.AnalyticsTabnow fetchesGET /events?page=1&limit=200directly (noupcomingparam — that filter is opt-in and off by default) and collects every distinct event/service-slot name with no date or usage filtering, dropping its dependency on the sharedhookprop entirely (<AnalyticsTab />takes none now). - Minister/Speaker filter —
MemberFilterInput(new, inapp/service-programme/page.tsx) is the same live/members?search=combobox asSpeakerInputused at creation time, minus the guest-name fallback (this is a filter, not an assignment field). Backend-side,memberIdrestricts the query to sessions this member actually appeared in via a session-slot subquery (session.id IN (SELECT ... service_session_slots ... WHERE ps.member_id = :memberId OR ss.overridden_member_id = :memberId)) — a raw SQL subquery rather than a plain join-then-filter, because filtering on a left-joinedsessionSlotsrelation directly would silently truncate that session’s OTHER slots out of the hydrated result (corruptingcompletionRate, which depends on the session’s full slot count). Within an included session, the per-slot accumulation loop also skips slots that aren’t this member’s, soslotTypeStats/topSpeakersreflect only their own contribution — whilecompletionRate/totalDurationMinutesstay session-wide (those describe the whole service, not one person’s slice of it). - Slot Type filter — a
<select>of the 8ServiceSlotTypeEnumvalues (reusingSLOT_TYPES/SLOT_TYPE_CONFIG, already defined in this file for the programme editor). Backend-side,slotTypedoesn’t remove sessions from the list (a service having no “Offering” segment isn’t itself meaningful to filter out) — it only restricts which slots feed intoslotTypeStats/topSpeakers/the per-sessionoverrunSlotscount, so “compare just Offering segments across every service” resolves to a single-row breakdown table instead of scrolling past 7 other types to find it. - Quick date presets (“7d” / “30d” / “This Quarter”) compute the range and fetch immediately on click, rather than just filling the date inputs and waiting for “Load” — since a preset click is already one deliberate action, requiring a second “Load” click after it would be redundant. They call
fetchAnalyticsdirectly with the freshly computed dates instead of going through the memoizedload(), for the same reasonload()itself doesn’t chase its own tail:setFrom/setTodon’t take effect until the next render, so calling the existingload()immediately afterward would still run with the previous (stale) range.
- Fixed: the “Service Slot Name” (and date-range) filters silently did nothing —
- Create Programme’s Event picker is now searchable — was a plain
<select>listing every open event; replaced with the sameSearchableSelectcomponent used by the headcount page’s Event/service-slot pickers (extracted to the sharedcomponents/ui/searchable-select.tsxrather than duplicated, and re-imported into the headcount page too). Each option’s sublabel shows the date and sub-service count, matching the old<option>text. - Full-event report downloads — the Programmes list groups by event already (
groupProgrammesByEvent); each event group’s header now has a “Full Report” button (GET /service-programme/event/:eventId/pdf, always available — the order-of-service across every sub-service regardless of session state) plus a “Session Report” button that only appears once every programme in the group isCOMPLETED(GET /service-session/event/:eventId/report/pdf, the post-service analytics report — timing, pauses, completion rate — which 400s if any session isn’t finished yet, so it’s hidden rather than shown-then-erroring). Both backend routes already existed and were unused by any frontend button before this. Each individual sub-service row also has its own small download icon (“download this service only”,GET /service-programme/:id/pdf) alongside the existing detail-panel download, for a quick single-service PDF without opening the panel. - “Start Service” sequential start — a multi-service Sunday starts one sub-service at a time in slot order (First Service, then Second Service, and so on), not all at once. Each event group’s header shows a “Start
<slot name>” button whenever at least one of its programmes is stillDRAFT, labeled with the earliest not-yet-started slot (getNextDraftProgrammeinpage.tsx, sorted byserviceSlotDetail.startTime). CallingPOST /service-session/event/:eventId/start(startEventSessionsinuse-service-session.ts) starts only that one programme and returns a single session (not an array). The backend rejects the call with 409 if a session for the event is alreadyLIVE— the current slot must be ended (POST /service-session/:sessionCode/end) before the button can start the next one. No new “EventSession” entity was introduced —ServiceSessionService.startEvent()still reuses the existing per-programmestart(), it just now picks the single earliestServiceProgrammeService.findStartableDraftProgrammesForEvent()result (that method sorts byserviceSlot.startTimeASC) instead of looping over all of them. The frontend button is disabled (with an explanatory tooltip) whenever any programme in the event group isLIVE, in addition to the backend’s 409 — so an admin can’t click it mid-service and only sees the error if state is stale.
- Item entry is inline, not modal-based (
Access control:
- Both controllers (
ServiceProgrammeController,ServiceSessionController) carry@RequiresPlan(PlanFeature.SERVICE_PROGRAMME)and@RequiresModule('service_programme')at class level, covering every route including the public share-token ones.service_programmeisrequired: trueinKNOWN_MODULES, soChurchSettingsService.upsertrefuses to let a tenant admin disable it — the module gate is enforced for consistency with every other Pro-plan-gated module (sermon, incident report, volunteer, asset management, facility rental, service ratings), not because it can actually be turned off. - Programme CRUD and reporting:
AdminGuard+SERVICE_PROGRAMME_READ(reads) orSERVICE_PROGRAMME_WRITE(mutations). Assign these permissions to admin roles via the role management API. - Authenticated session control (start, advance, rewind, pause, resume, adjust-time, reorder, end): controller only requires
JwtAuthGuard(any authenticated member or admin); the real rule is enforced once inServiceSessionService.assertCanControlSession()— passes for either of two groups, both individually attributable via their own authenticatedmemberId(no PIN/name workaround needed for either): an activeAdminentity holdingSERVICE_PROGRAMME_WRITE, or a worker in a department with theFRONT_DESK_OPERATIONScapability (checked viaDepartmentAccessService.hasCapability, the same non-throwing boolean checkdiscuva-member’s front-desk control page (app/front-desk-session/) relies on to decide whether to show that tile at all). This reverses an earlier, narrower version of this rule that removed department-based control access entirely (reserved for a mobile worker UI that hadn’t been built yet against these endpoints) — that UI now exists, so the capability check was restored. Anyone in neither group — an external production collaborator with no Discuva account, for instance — still controls a session exclusively through the public Programme Manager link with a named PIN grant (see below). This does not affectgetEventSummaryReportPdfForWorker(GET /service-session/event/:eventId/summary-pdf, a read-only mobile PDF download) — that keeps its own narrowerassertIsAdminDeptWorker()check (Admin-department worker, noSERVICE_PROGRAMME_WRITEfallback needed), deliberately kept separate from session-control access. - Public Programme Manager routes (
POST/PUT /service-session/:code/pm/*— advance, rewind, pause, resume, adjust-time, reorder, end):@Public()+ShareTokenGuard(?token=), andNamedAccessGuard(?grantToken=). The share token only proves “has the link”; every PM-link holder used to be logged as the same genericPUBLIC_LINKactor with no way to tell people apart or revoke one person without rotating the link for everyone.NamedAccessGuardlayers named, individually-revocable identity on top: an admin/worker callsPOST /service-session/:code/access-grants({ name },JwtAuthGuard+assertCanControlSession) to generate a 6-digit PIN for a collaborator (randomInt-generated, argon2-hashed viaUtilityService, returned in plaintext exactly once — never stored or retrievable again). That person then calls the publicPOST /service-session/:code/pm/access(ShareTokenGuardonly — this route establishes identity, so it can’t itself requireNamedAccessGuard) with{ name, pin }; on successverifyAccessGrantissues agrantToken(random UUID, Redis-cached alongside{ grantId, name }, same TTL as the session) that must be appended to every subsequentpm/*write call.NamedAccessGuard.resolveGrantTokenresolves that token, re-checks the underlyingServiceSessionAccessGrantrow’srevokedAton every call (not just at sign-in), and stamps the grant’s name onto the request (@ActorLabel()) so it flows through tologAction’sactorLabelcolumn — surfaced as theactorNamefallback ingetActionLog/getActionLogCsvwhenever there’s noperformedByMember. Revoking viaPOST /service-session/:code/access-grants/:grantId/revoketakes effect on that person’s very next action, without touching the shared link or anyone else’s grant. Grants are scoped to a single session (tableservice_session_access_grants,session_idFKON DELETE CASCADE) — a new PIN is needed each time someone needs PM access to a new session, by design (no cross-session standing identity to manage). The frontend Live Session Dashboard’s “Programme Manager Access” panel manages grants (add/list/revoke, showing the PIN once); the public/live/:code/manageview gates itself behind a name+PIN sign-in form the first time, then caches the resultinggrantTokeninlocalStorage(keyed per session) so it isn’t re-prompted on every visit, clearing that cache automatically if any action comes back with a revoked/expired-access error. - Duplicate active names are rejected, not silently allowed — two active grants sharing a name make sign-in ambiguous (the name+PIN lookup could match whichever row it finds first, so a correct PIN for the second grant could get rejected).
generateAccessGrantpre-checks for a non-revoked grant with the same name (trimmed, case-insensitive) and throwsConflictException(409) instead of creating the duplicate; a partial unique index (uq_service_session_access_grants_session_name_activeon(session_id, lower(name)) WHERE revoked_at IS NULL, migrationAddServiceSessionAccessGrantUniqueActiveName) catches the same race at the DB level as a safety net, translated back into the same 409. The caller can passreplaceExisting: trueonPOST /service-session/:code/access-grantsto confirm the swap — this revokes the old grant (logged asACCESS_GRANT_REPLACED) and issues a fresh PIN under the same name in one call. The Dashboard’s “Programme Manager Access” panel surfaces this as a “Replace?” prompt when adding a name that’s already active, rather than a bare error. overrideSlot(speaker runtime override) staysRolesGuard (WORKER)+FRONT_DESK_OPERATIONScapability check only — not exposed in the admin dashboard.- Session state read (
GET /service-session/:code/state) and speaker slot view (GET /service-session/:code/slots/:position):@Public()— session code is the access credential. (These are read-only; the share token, not the session code, gates writes.) ADMIN_WRITEpermission controls who can assignSERVICE_PROGRAMME_READ/SERVICE_PROGRAMME_WRITEto admin roles.
WebSocket:
- Namespace:
/service-session— this is the primary live-update channel for all four frontend session views (see the “Live updates moved from per-client polling to Socket.IO push” bullet above); each view keeps only a slow 30s safety-net poll of the REST routes as a fallback. - Client event
joinSession({ sessionCode })→ validates the session exists (callsgetState()) before joining roomsession:{sessionCode}; on failure the client is not joined and receivessession:error({ message }) instead. - Client event
leaveSession({ sessionCode })→ leaves room - Server event
session:state→ the fullSessionStatePayload({ anchor, session, effectiveSlots, cautionThresholdRatio }) — the same shapeGET /service-session/:code/statereturns, emitted after every mutation (advance/rewind/pause/resume/adjustTime/reorderLiveSlots/overrideSlot/end, both authenticated andpm/*variants). - Server event
session:error→{ message: string }, emitted only on a failedjoinSession. - No authentication is required to join a room — session code is the read credential, matching the trust model of the public REST routes this channel replaces for live viewers. No write actions happen over the socket.
- Redis adapter:
@socket.io/redis-adapter(RedisIoAdapter) is used instead of the default in-memory adapter. Events broadcast on one backend instance are forwarded to all other instances via Redis pub/sub, making horizontal scaling safe. - CORS: validated via
createCorsOriginValidator()(same shared validator as the HTTP API’sapp.enableCors()), not a wide-openorigin: '*'.
Routes prefix: /service-programme, /service-session
Games Module
Kahoot-style live quiz for member engagement. A Game (title, description, optional department/churchClass for
admin-side categorization/reporting only — not access control) holds an ordered list of GameQuestions and is a
reusable definition that can be run multiple times via a GameSession. Games can only be created/edited on the admin
portal (GAMES_WRITE); anyone with a session’s join code can participate — no department/class/role gating on the
participant side, by explicit product decision.
Game lifecycle: Game.status tracks whether it’s still being edited (DRAFT) or currently backing a live session
(LIVE_SESSION_ACTIVE) — it’s informational, not a gate; admins can always edit a DRAFT game’s questions.
startSession requires at least one question and flips the game to LIVE_SESSION_ACTIVE; endSession reverts it to
DRAFT — but only if no other session for the game is still LIVE (see below), so the same game can be started
again later. startSession also 400s if a LIVE session already exists for the game — previously a double-click or
a second admin starting the same game would silently orphan the first session (its join code still worked, but
nothing in the UI could get back to it).
Game.status is a denormalized mirror of “does this game have a live session”, and — as the guard above and the
defensive check in endSession both exist to address — it can drift out of sync with reality (e.g. a session left
LIVE from before either of those existed, or any future code path that touches a session without going through
this service). listGames/getGame therefore return an activeSessionCode field sourced directly from
GameSession (querying every LIVE session for the games in the batch), not gated by Game.status === LIVE_SESSION_ACTIVE — so the admin portal’s “Resume Control” action stays correct and discoverable even for a game
whose status wrongly says DRAFT while a session is still actually running underneath it. endSession mirrors
this: before resetting Game.status to DRAFT, it re-checks for any other still-LIVE session for the same game
and skips the reset if one exists, so ending one orphan can’t stomp on a genuinely-live sibling session.
Same batch call also attaches playCount — a count of each game’s ENDED sessions, grouped in one query rather
than N+1. Game.status reverting to DRAFT after every session ends (not to some distinct “played” state) meant
the admin list showed the identical “Draft” label for a game that had genuinely never been touched and one that had
just been run ten times — playCount is what actually distinguishes those two cases in the UI (see the
gameStatusDisplay note under discuva-admin below), not a change to Game.status itself.
Session lifecycle, with a real lobby: GameSession.sessionCode (GAME-XXXXXX, same random-alphanumeric
generation as ServiceSession.sessionCode) is the join credential — no auth beyond being a logged-in member/worker is
required to join. startSession creates the session LIVE but with currentQuestionIndex/currentQuestionStartedAt
both null — this is the lobby: members can join and see the code, but no question’s timer starts until the host
explicitly reveals Question 1. Previously startSession set currentQuestionIndex = 0 and stamped the timer
immediately, meaning Question 1’s clock started the instant the button was clicked, before any member could possibly
have joined. nextQuestion’s (currentQuestionIndex ?? -1) + 1 already handled the null → 0 transition correctly
with no other change needed — the fix was purely in what startSession writes. nextQuestion 400s if already on
the last question (call endSession instead). hostAdmin is recorded at start — only that admin (or any admin if
hostAdmin was somehow cleared) can advance the session via nextQuestion (ForbiddenException otherwise).
endSession is deliberately NOT host-restricted — it’s the safety valve for a session whose host closed their
tab without ending it themselves (the only way to clear a game stuck LIVE, since startSession blocks starting a
new one while any session for the game is still live); any admin with GAMES_WRITE can end one, with the actual
actor still traceable via the GAME_SESSION_ENDED audit log entry. endSession itself remains idempotent (a second
call is a no-op, not an error).
Countdown (GameSessionStatePayload.currentQuestionStartedAt): the payload carries the current question’s start
time as epoch ms alongside the existing secondsRemaining snapshot. secondsRemaining is only accurate as of the
moment the payload was generated — a client that just renders it verbatim sees the countdown freeze between socket
broadcasts (previously the only source of updates: nextQuestion, endSession, or the 30s safety poll) instead of
ticking down every second. currentQuestionStartedAt lets a client compute its own live countdown
(timeLimitSeconds - (Date.now() - currentQuestionStartedAt) / 1000, ticked with a 1s setInterval), the same
pattern ServiceSessionController’s anchor.slotStartedAt already uses for the service-programme timer.
Scoring (GameService.computeScore): speed-bonus model — pointsAwarded = isCorrect ? round(question.points * max(0.5, remainingTimeFraction)) : 0, where remainingTimeFraction is computed from currentQuestionStartedAt (a
server-side clock all participants are scored against, not each client’s own page-load time) versus the question’s
timeLimitSeconds. An instant correct answer earns full points; a correct answer submitted right at the deadline
earns 50% of the question’s points; any incorrect answer earns 0. GameParticipant.totalScore is a running total,
incremented per response — the leaderboard itself is always computed live from GameResponse rows
(SUM(pointsAwarded) effectively, via totalScore and a ORDER BY totalScore DESC read), not a separately-audited
aggregate.
Answer submission (POST .../answer) is a normal REST call, not a socket message — matches this codebase’s
discipline of keeping every scored/audited action behind the guard+validation layer. It’s rejected (400) if the
session isn’t LIVE, if the question isn’t the session’s current question (guards against a stale client
answering a question that’s already advanced past), if the participant already answered it (also enforced at the
DB level via a unique constraint on (session_id, question_id, participant_id) — the pre-check leaves a race window
under a concurrent double-submit, so submitAnswer also catches that constraint’s violation (Postgres 23505) and
returns the same 400 rather than letting a raw DB conflict surface as a 500), or if it’s past the time limit —
ANSWER_GRACE_SECONDS (2s) of slack past question.timeLimitSeconds, measured from currentQuestionStartedAt.
Previously there was no server-side time check at all — a late answer (while the question was still current) just
scored at the MIN_SPEED_BONUS_FRACTION floor rather than being rejected, so answering was silently unbounded as
long as the host hadn’t advanced yet. The grace window exists because clients count down locally from
currentQuestionStartedAt, so a click registered right at 0s legitimately lands at the server a beat later on
network/render time. 403 if the caller never called join first.
What participants never see: GameSessionStatePayload (both the REST GET .../state response and the
session:state socket broadcast) never includes correctOptionIndex — the admin presenter view, which does need to
know the answer while presenting, already has the full question (with the answer) from its own authenticated
GET admin/games/:id/questions fetch, so the shared broadcast payload stays participant-safe without needing two
different payload shapes for the same event.
Routes (admin, AdminGuard):
POST/GET/PATCH/DELETE admin/games(/:idfor single-record routes) —GAMES_READ/GAMES_WRITEGET/POST admin/games/:id/questions,PUT admin/games/:id/questions/reorder,PATCH/DELETE admin/games/questions/:questionId—GAMES_READ/GAMES_WRITEPOST admin/games/:id/start,POST admin/games/sessions/:code/next-question,POST admin/games/sessions/:code/end—GAMES_WRITEGET admin/games/sessions/:code/state,GET admin/games/sessions/:code/leaderboard—GAMES_READGET admin/games/:id/sessions?page=&limit=—GAMES_READ. Past (and current, LIVE included) sessions for a game —GameSessionSummary[]:sessionCode,status,startedAt,endedAt,participantCount,topScore,topScorerName. The session data already fully persisted (GameParticipant.totalScore,GameResponse’s per-answer audit trail) — this closes the actual gap, which was discoverability: no way to find a session’s code again once you’d navigated away from wherever it was started.topScore/topScorerNamecome from aDISTINCT ON (session_id)query orderedsession_id ASC, total_score DESC, created_at ASC— thecreated_attie-break matters, since without it Postgres’s pick among equal top scores is arbitrary and “the winner” would flicker between requests. IncludingLIVEsessions (not justENDED) means this list doubles as the “resume/end a stuck session” surface for a game whose host closed their tab without ending it.
Note: games/sessions/:code/state on the participant controller below is @Public(), not JwtAuthGuard-gated.
Routes (participant, JwtAuthGuard + @RequiresModule('games')):
POST games/sessions/:code/join,POST games/sessions/:code/questions/:questionId/answer,GET games/sessions/:code/leaderboard— any authenticated member/worker, no department/class/role gatingGET games/my-history?page=&limit=—MyGameHistoryEntry[]:sessionCode,gameTitle,playedAt,totalScore,rank,participantCount,correctCount,answeredCount.ENDEDsessions only — a mid-flightLIVEscore isn’t a result yet, and its rank could still change.rankcomes from a windowedRANK() OVER (PARTITION BY session_id ORDER BY total_score DESC)raw query (notROW_NUMBER()— tied scores must share a rank rather than being arbitrarily split), with the calling member’s own filter applied outside the window; filtering inside it would make the window see only that member’s single row and always return rank 1.GET games/sessions/:code/state—@Public()(still behindModuleEnabledGuardand a300/60sthrottle, same shape asServiceSessionController’s public:sessionCode/state), so a projector/second-laptop presentation screen can poll it with just the join code — no member/admin login needed. Never leakscorrectOptionIndex(see above), so widening this one route to unauthenticated access doesn’t change what it exposes.
WebSocket:
- Namespace:
/game-session—joinSession({ sessionCode })/leaveSession({ sessionCode })client events, roomgame-session:{tenantId}:{sessionCode}, server eventsession:statecarrying the fullGameSessionStatePayload, emitted by the controller (not the service, same split asServiceSessionController/ServiceSessionGateway) after every mutating admin or participant action. - Bug fix: this gateway never actually worked.
TenantMiddlewareis applied via.forRoutes('*')(tenant.module.ts), which is HTTP-route-only and never runs for WebSocket messages — sohandleJoin’s call intoGameServicehad no CLS tenant context, no schema resolved, and every tenant-scoped repository query silently fell through to whatever the default connection resolved to (public.game_sessionsis a dead legacy table from an early migration), meaninggetSessionOrThrowalways threw and every join silently failed. Neither client noticed: discuva-admin’s socket hook treatsconnected: trueas a signal to disable its own polling safety net, and nothing listened forsession:error— so the presenter panel and projector screen have been running on re-fetch-on-action alone this whole time, never actually receiving a live push. Fixed with a realhandleConnection: resolves tenant fromclient.handshake.auth.token(a JWT, which already carries bothtenantIdandschemaNameas claims — verified against the access secret, then the refresh secret, same two-try pattern asServiceSessionGateway.verifyTenantClaim) for an authenticated client, or fromclient.handshake.auth.tenantSubdomainvia a plain public-schemaTenantrepo lookup for the unauthenticated projector screen (which has no JWT at all). The resolved{tenantId, schemaName}is stored onclient.data, andhandleJoinwraps itsGameService.getSessionStatecall inrunInTenantContext(tenant/utility/run-in-tenant-context.ts— the same helper Bull processors use to re-enter tenant context outside an HTTP request) rather than calling it bare.broadcastStatereadstenantIdoffClsServicedirectly, since it’s always invoked from inside the HTTP request that just performed the mutation. The room key gained atenantIdsegment as a side effect —sessionCodeonly has a per-schema unique index, not a cross-tenant one, so without it two different churches could theoretically collide onto the same room. - No authentication required to join beyond a resolvable tenant — session code is the read credential. Answer submission itself never happens over the socket (see above).
joinSessionreports its outcome via a Socket.IO acknowledgment, not a separate emitted event. Two patches were tried and superseded before landing here, both worth naming since either could resurface as a regression: (1) originally,connectedflippedtrueon the bare transportconnectevent — wrong, sinceconnectfires even when the follow-up join is then rejected server-side, so a client with no resolvable tenant looked “connected” while never actually receiving anything. (2) Fixed by flippingconnectedonly on actually receiving asession:statepush, withhandleJoinemitting the state it had already fetched (to validate the session exists) back to the joining client — but a session with nothing yet mutated since the join (a fresh, still-empty lobby, the common case) triggers nobroadcastStatefrom anywhere else either, so that client got no event at all: not an error, not a state push, stuck indefinitely on “Reconnecting…” despite the join having actually worked. Both patches shared the same root flaw — inferring “did my join succeed” from a separately listened-for event that may or may not correlate with this specific attempt. The real fix:handleJoinnow returns its result directly (Promise<{ok: true, state} | {ok: false, message}>) rather than emitting anything itself; Nest’sWsAdaptersends a handler’s return value back as the argument of whichever callback the client passed to its ownsocket.emit('joinSession', payload, callback)call — Socket.IO’s acknowledgment mechanism, built for exactly this “did this one request succeed” question. Both client hooks now readconnectedstraight off that ack, with no separate event to miss.session:errorno longer exists as an emitted event at all — every failure mode a client needs now arrives as{ok: false, message}on the same ack the success case uses.
Routes prefix: /admin/games, /games
Admin frontend UX (discuva-admin): mirrors the service-programme live-session split between a control surface
and a screen-safe display, rather than the one page both were previously crammed into. app/games/present/[code]
(withAuth, now games:read — was games:write, relaxed since this page doubles as the read-only results view for
a past session, see below) is the host’s control panel — question preview, the ticking countdown, Next Question/End
Session, leaderboard — plus a “Copy Link”/“Open Screen” action for the presentation view. The Next Question/End
Session controls themselves stay gated on hasPermission("games:write") client-side, so a read-only admin sees
everything but can’t act on it. That view lives at app/games-screen/[code] (outside app/games/, so it isn’t
wrapped in app/games/layout.tsx’s admin Shell chrome) and is unauthenticated, full-bleed, dark-themed, big-type —
built to be opened on a projector or a second laptop via the copied link, same pattern as /live/[code]/presentation.
Both pages tick a local setInterval(() => setNowMs(Date.now()), 1000) and derive secondsRemaining from
currentQuestionStartedAt via calcGameSecondsRemaining() (hooks/use-games.ts) instead of rendering the payload’s
secondsRemaining snapshot directly, fixing a bug where the on-screen countdown only changed when a broadcast
arrived instead of counting down every second. The games list (app/games/page.tsx) and detail
(app/games/[id]/page.tsx) pages surface a “Resume Control”/“Resume Live Session” action off the new
activeSessionCode field for any LIVE_SESSION_ACTIVE game — and, alongside it, an “End” action (calling the same
now-non-host-restricted POST .../end endpoint) so a session left LIVE by a host who closed their tab without
ending it can be cleared without needing to know its code.
Status label and a direct “Start” action on the games list (app/games/page.tsx). Two related fixes:
Game.statusreverting toDRAFTafter every session ends (not to some distinct “played” state) meant a game that had been run ten times and one that had never been touched both showed the identical “Draft” badge —gameStatusDisplay()now reads the newplayCountfield alongsideisLiveto show “Draft” only for a game with zeroENDEDsessions, “Ready” for one that’s been played before and is currently idle, and “Live” as before. A “Played” column (N×, or—at zero) sits next to it.- Starting a session previously required going through the row’s “Questions” link — labeled and built as the
question editor, with “Start Live Session” tucked into that page’s header — so the only path to actually launching
a game read as “go edit questions” first. A “Start” button now sits directly on the list row (next to “Questions”,
shown whenever the game isn’t already live), calling
POST admin/games/:id/startand navigating straight to the control panel on success — the exact same requestapp/games/[id]/page.tsx’s own button already made, just reachable without the detour. No question-count pre-check was added to gate the button — the backend’s own 400 (“Add at least one question before starting a session”) surfaces as a toast on failure instead, the same pattern the adjacent “End” action already uses (which also gained toast error surfacing here, having previously failed silently).
Bug fix: the presentation screen never actually loaded, for any tenant, ever. app/games-screen/[code] is
deliberately public (no login, so it can run unattended on a projector) — but every API call from discuva-admin
flows through a shared axios client whose base URL is computed from window.location.hostname, and this app is a
single shared host in production with no per-tenant subdomain of its own (tenant normally comes from the logged-in
admin’s JWT instead — see utils/tenant/api-base-url.ts’s own comment). A public route has no JWT and no subdomain,
so it had no way to identify which church’s session to look up — confirmed via a direct curl of the underlying
public API endpoint, which 404s {"message":"Tenant not found"} without a tenant header and returns full data with
one. Fixed with a dedicated axios instance (utils/games/screen-api.ts, withCredentials: false, no Authorization
interceptor) rather than a flag on the shared client — the shared client’s cookie/JWT would otherwise silently win
over any override on the exact machine most likely to test the link (the host’s own logged-in browser), per the
backend’s tenant-resolution precedence. The tenant subdomain travels as a ?t= query param on the link
app/games/present/[code] generates (now reading subdomain off GET /tenant/info, a genuinely reliable
client-side “which tenant is this” source, rather than the pre-existing localStorage-remembered value from the
login form, which the codebase’s own comments already flagged as best-effort-only).
Bug fix: the socket layer these two pages both use had never actually worked — see the WebSocket section above
for the full history (two superseded patches before landing on Socket.IO acknowledgments). hooks/use-game-session-socket.ts
now derives connected from the joinSession call’s own ack callback — socket.emit('joinSession', {sessionCode}, (ack) => {...}) — rather than any separately listened-for event, so there’s no “did I miss the event” gap to have
regardless of whether the room is busy or completely quiet. Both pages’ 30s safety poll stays gated on !connected
exactly as before; it just now reflects reality correctly. auth: { token, tenantSubdomain } is passed on connect —
a JWT for the authenticated control panel, the same ?t= subdomain for the public screen (see
game-session.gateway.ts’s handleConnection) — and re-sent automatically on every reconnect, since socket.io-client
re-fires connect (and this hook re-joins) on its own after a dropped connection recovers.
A real lobby, not just “Get ready…”. Previously GameScreenDisplay rendered a static placeholder whenever
currentQuestion was null; now that this state is actually reachable (session created, no question live yet — see
the API-side lobby fix above) it renders a real Lobby: the join code in large type, a QR code (qrcode, already a
dependency from Forms sharing — no new one added) pointing at the member app’s join URL for the resolved tenant, and
a live “N joined” count. The control panel’s own “Question X of Y” line is suppressed during this state (it
previously showed “Question 1 of N” while simultaneously saying “Waiting to start the first question…” directly
below it — a real, if cosmetic, contradiction) and its Next-Question button relabels to “Begin Game” for this one
first press, since it’s a semantically different action from advancing past an already-revealed question even
though it’s the same endpoint call.
Live-joining names on the projector (JoinedNames, game-screen-display.tsx) — a “1 joined” count alone gives
a room no sense of who’s actually there or that the screen is live at all. Every participant is tied at 0 points
during the lobby, so GameSessionStatePayload.leaderboard — already computed unconditionally by getSessionState,
no backend change needed — is, at that point, simply “who’s joined so far,” ordered by join time (the same
createdAt tie-break getLeaderboard already applies elsewhere). Rendered as a wrapping row of name pills using
the same motion (LazyMotion + domAnimation + m) already adopted for leaderboard reordering, plus
AnimatePresence for the enter/exit transition — each pill keeps its participantId as its key across every
poll/broadcast, so a new arrival pops in on its own instead of the whole list re-animating on every update. Capped
at 24 visible names (MAX_VISIBLE_JOINERS) — showing the most recent joiners, not the earliest, so a brand new
arrival is always visible even once a room is past the cap — with a plain “+N more” beyond that, so a genuinely
large room doesn’t turn into visual noise.
Made more prominent, to actually encourage joining, not just confirm it: three follow-up additions, all on the
same lobby. (1) JoinedCount replaced the small static “N joined” line with a large tabular-nums number that
animates smoothly from its previous value to the new one on every change (useAnimatedCount) — a number visibly
climbing reads as a room filling up live; a static label doesn’t. (2) useLatestJoinerHighlight tracks whichever
participant most recently joined and returns their id for 4 seconds (RECENT_JOIN_HIGHLIGHT_MS) — deliberately
does not highlight anyone already present on the screen’s first render (it could load after several people have
already joined; only genuinely new arrivals after that count), comparing each update’s participant-id set against
the previous one it already recorded. (3) That highlighted pill gets a distinct amber ring/fill and briefly scales
up (1.12×, settling back to 1× via the same spring once the highlight expires), paired with a PartyPopper
“{name} just joined!” callout above the pill row — a real Lucide icon, not an emoji character, matching this
codebase’s convention elsewhere.
Found and fixed alongside this: the socket handleJoin bug that left a genuinely idle lobby (exactly this screen,
on a real fresh session) stuck on “Reconnecting…” forever — see the WebSocket section’s own note on it above.
Past sessions, surfaced (app/games/[id]/page.tsx): a new “Past sessions” table below the question builder,
backed by GET admin/games/:id/sessions — each row (code, status, start time, participant count, top scorer) links
to app/games/present/[sessionCode], which now renders read-only for an ENDED session regardless of the viewer’s
write permission (see the games:read relax above). Closes the actual gap behind “no record of who won” — the
per-session data already existed, there was just no way to find a past session’s code again once you’d navigated
away from wherever it was started.
GameSessionStatePayload.gameTitle (session.game.title) is included so the control panel and the presentation
screen can both display which game is running instead of just the join code — most visibly on the end-of-session
card, which previously only said “Session Ended” with no indication of which game just finished. Both now lead with
the game’s title and a warmer “That’s a wrap” framing, plus (on the control panel) the leaderboard’s #1 entry inline
(“{name} takes the win with {score} pts!”).
Member-facing player (discuva-member mobile, components/layout/game-session.tsx, hooks/use-game-session.ts): this
surface fetches the same public GameSessionStatePayload but originally polled it on its own schedule (LIVE_POLL_MS
= 2s while a question is active, no socket) rather than sharing the admin/screen views’ socket-driven state — it had
fallen out of sync with the countdown fix above (rendering the raw secondsRemaining snapshot, only visibly updating
once per poll) and was missing the gameTitle/currentQuestionStartedAt fields entirely. Brought in line:
calcGameSecondsRemaining() (mirroring the same-named helper in use-games.ts) ticks a local nowMs every second
off currentQuestionStartedAt, and the game title now renders above the question.
Now on the same socket as discuva-admin, not polling alone. This app previously had no real-time client
precedent at all, so the 2s/5s adaptive poll above was a deliberate choice at the time. Once the gateway’s
tenant-context bug was fixed (see the Games Module WebSocket section above) and discuva-admin’s socket hook actually
started working, that reasoning no longer held — hooks/use-game-session-socket.ts is ported over (new
socket.io-client dependency, none existed here before), and useGameSession now applies session:state pushes
directly via the socket, falling back to the same 30s safety-net poll discuva-admin’s control panel/presentation
screen already use (enabled: !connected) rather than the original always-on fast poll — connecting with the
member’s own JWT (tokenStore.get()?.accessToken), which already carries schemaName, so no subdomain path is
needed here the way the public admin projector screen requires.
Bug fix: a failed answer submission locked the member into “answered” forever, with no explanation.
QuestionCard.handleAnswer set its local “selected” state the instant a button was tapped, before the request even
resolved — so a rejected submission (already answered, the question advanced underneath it, or now, past the new
server-side time limit) left the UI permanently stuck on “Answer submitted — waiting for the host…”, and
useSubmitAnswer’s own error state, though correctly populated, was never even read by the component. Fixed by
splitting a pendingIndex (set optimistically, cleared on failure) from a submittedIndex (set only once the
server actually confirms it) — only the latter drives the “answered” lock, and error is now rendered so a failure
is visible and, for a retriable cause, doesn’t leave the buttons dead. Option buttons also now disable once
secondsRemaining hits 0, pre-empting the new server-side time-limit rejection rather than trading one confusing
failure for another. Regression-tested in components/layout/__tests__/game-session.test.tsx.
A real lobby here too. The “Waiting for the host to start the first question…” state was already correctly
rendered before this session’s fixes — it just wasn’t reachable, since startSession used to begin Question 1
immediately. Now that it is, it got real treatment instead of staying a placeholder: a pulsing icon, “You’re in!”,
and a live participant count.
Standings are never shown to a member while the game is still LIVE — only once it ends. A compact live
leaderboard used to render under every question and in the lobby itself, updating as scores came in — which gave
away the whole competitive outcome well before the game actually finished, with no suspense to the reveal at all.
Removed entirely from every LIVE state (lobby included); Game Over!'s full LeaderboardList remains the only
place a member ever sees where they landed. LeaderboardList’s now-unused compact prop (only ever passed by the
removed call site) was removed along with it, rather than left as dead flexibility.
Leaving mid-game now needs confirming. Checked directly and confirmed there was no guard at all — the header’s
back button navigated away instantly, and neither the device/browser back gesture nor closing the tab were
intercepted in any way. Guarded whenever status === 'LIVE' (lobby or mid-question — ENDED has nothing left worth
guarding), via three mechanisms: the in-app back button, a beforeunload listener prompting the browser’s own
native dialog on a tab close/refresh, and a popstate listener for the device/browser back gesture specifically —
a sentinel history entry (window.history.pushState({gameGuard: true}, "")) is pushed the moment the game becomes
active and re-pushed immediately on every popstate (synchronously canceling the actual navigation, independent
of what’s decided next — the browser would already be gone before there was anything left to ask, otherwise).
Not window.confirm(), on reflection — a real in-app modal instead, matching this codebase’s own precedent
(plan-gate-modal.tsx, this app’s one other confirmation dialog) rather than the first pass at this fix, which did
reach for window.confirm(). A native confirm dialog can’t be styled or branded, only offers generic OK/Cancel
button labels instead of something like “Leave Game”/“Stay”, and looks especially out of place in an installed PWA
with no browser chrome around it to visually anchor it. The back button and the popstate handler both now just
call setShowLeaveConfirm(true) instead of blocking synchronously on window.confirm() — the modal’s own
“Stay”/“Leave Game” buttons drive the actual decision asynchronously.
Extracted into a shared ConfirmModal (components/ui/confirm-modal.tsx), reusing the same
backdrop/card pattern PlanGateModal established (fixed inset-0 z-[60] backdrop + bg-white rounded-2xl card),
rather than leaving the pattern inlined once per call site (it started as a local LeaveGameConfirm function in
game-session.tsx, now removed in favor of the shared component). Takes title/message/confirmLabel/
cancelLabel/onConfirm/onCancel, plus a variant: "destructive" renders a red confirm button for an action
that actually changes or ends something, "default" a plain dark one. Applied to every remaining
window.confirm() call site in this app — there were two: game-session.tsx’s leave-game guard (above), and
front-desk-session.tsx’s “End this service?” (handleEnd used to block synchronously on
window.confirm("End this service? This can't be undone."); now showEndConfirm state opens the modal, and the
actual end(sessionCode) call moved into a handleConfirmEnd fired only from the modal’s “End Service” button).
Game history (GET games/my-history, app/games/history/page.tsx, components/layout/games-history.tsx,
hooks/use-game-session.ts’s useMyGameHistory): the first member-facing surface for “did I win,” following the
same page/component split app/service-history/page.tsx → components/layout/service-history.tsx already
establishes — a card per past session (title, date, score, rank, correct/answered count), linked from the join
screen (components/layout/games-join.tsx).
Engagement polish, both frontends: evaluated against the five effects wanted (question transitions, timer
urgency, correct/incorrect reveal, score count-up, a game-over celebration) plus live leaderboard reordering, and
landed on motion for exactly one of them — reordering — with everything else staying plain CSS:
- Leaderboard reordering (
motionv12, new dependency in both repos): rows sliding to their new rank as scores change is a real FLIP animation, genuinely painful to hand-roll from raw DOM rects, and the one effect in this pass that earns a library. Imported viaLazyMotion+domAnimation+ the lightweightmcomponent (not the fullmotioncomponent) to keep the bundle cost down (~15 KB gz vs ~35 KB) — nothing here needs gestures or drag.discuva-member’sLeaderboardList(components/layout/game-session.tsx) wraps each row in<m.div layout>, keyed byparticipantId.discuva-admin’s present-page leaderboard was a<table>/<tr>structure —layoutanimation doesn’t play well with the browser’s own table layout algorithm, so it was converted to a plain flex-row<div>list first (same visual spacing, now genuinely animatable).motionrespectsprefers-reduced-motionautomatically; nothing extra was needed there. - Question transitions (CSS only, free):
QuestionCardalready remounts per question (key={question.id}at the call site), so ananimate-fade-in-upclass (reusing a keyframe that already existed inapp/globals.cssbut was unused) is the entire enter animation — no library, no manual reset logic. - Timer urgency (CSS only): a new
animate-timer-urgentkeyframe (a scale pulse, distinct from the existing red color-change threshold) applied at ≤5s remaining, in both the member countdown and the projector screen’s giant one (game-screen-display.tsx). - Correct/incorrect reveal (CSS only):
animate-answer-correct(a small pop) /animate-answer-incorrect(a shake) on the selected option button the instant a result arrives, member-side only — the admin/projector views never show which option a specific member picked. - Score count-up (CSS-adjacent, no library): a ~25-line
useCountUphook (game-session.tsx) animates 0 → the awarded points over ~500ms viarequestAnimationFrameinstead of the number just appearing, resetting via a deferred rAF callback (not a synchronoussetStatein the effect body — this codebase’s ownreact-hooks/set-state-in-effectlint rule catches that pattern) whenever a fresh question arrives. - Game-over celebration (
canvas-confetti, new dependency in both repos, ~2.5 KB gz, no React coupling): fires once (auseRefguard, since the ENDED state can re-render repeatedly from the safety poll/socket without actually re-firing) on the member’s “Game Over!” screen and the projector’sFinalResults— the latter matters most, since it’s the one screen a whole room is actually watching together. Both skip it entirely underprefers-reduced-motion, checked viawindow.matchMedia. prefers-reduced-motionreset: discuva-admin’sapp/globals.csshad no such block at all before this — added the same wildcardanimation-duration/transition-durationoverride discuva-member’s already had.
Admin question-builder (app/games/[id]/page.tsx, QuestionForm.removeOption): deleting the option currently
marked correct used to silently reassign “correct” to whichever option shifted into that slot instead of clearing
the selection — e.g. options [A,B,C,D] with C marked correct, deleting C, silently left B marked correct
with no admin action. Now deleting the correct option resets correctOptionIndex to an unset sentinel (-1) and
canSubmit requires a non-negative index, so the form blocks saving until the admin explicitly re-picks the correct
answer.
“Status stays on Draft” investigation: confirmed via direct testing that startSession/endSession flip
Game.status correctly and immediately server-side (this Next.js version’s client Router Cache also defaults
staleTimes.dynamic to 0, so it wasn’t a caching issue either). The frontend list/detail pages
(app/games/page.tsx, app/games/[id]/page.tsx) still call router.refresh() after starting/ending a session, and
listen for pageshow/visibilitychange to refetch if the page was restored from the browser’s back-forward cache
(bfcache) — real, if secondary, sources of staleness for a plain client-fetched list. But the actual bug reproduced
in this instance was the Game.status drift described above: a session left LIVE from before the duplicate-session
guard existed meant a later session’s endSession call reset Game.status to DRAFT while the orphaned earlier
session was still LIVE underneath — so the list correctly showed DRAFT (matching Game.status), while
startSession correctly 400’d on any attempt to start a new one, with no UI path back to the still-live orphan since
activeSessionCode was, at the time, also gated on Game.status === LIVE_SESSION_ACTIVE. Sourcing
activeSessionCode directly from GameSession (above) closes that gap.
Service Rating Module
Member-to-church pulse after a service — a 1–5 star rating plus optional comment, keyed off (event, serviceSlot, member) (the same pair Attendance keys off, not ServiceSession — the live-control-room entity, which isn’t
guaranteed to exist for every service). Structurally distinct from the Pastor Feedback module: Pastor Feedback is
worker/HOD → leadership, weekly, per-department narrative reporting; this is member → church, per-service,
numeric+comment. POST service-ratings upserts — one rating per member per service occurrence, submitting again
edits the existing row in place.
Anonymity design: the admin comment feed (GET admin/service-ratings/comments) never joins/exposes member
identity unless the requesting admin’s role includes SERVICE_RATING_MODERATE — service_rating:read alone gets an
aggregate view and an anonymized comment feed (member: null per row); service_rating:moderate additionally
reveals member: { id, firstname, lastname } on the same response and is required to delete/hide a comment
(DELETE admin/service-ratings/:id). This is deliberate: default admin access should give a pulse on sentiment
without turning ratings into a place to publicly identify who said what about a specific worker/sermon.
Index: getComments() filters WHERE comment IS NOT NULL ORDER BY created_at DESC across all events (a global
moderation feed, not scoped to one event/slot) — served by a partial index,
IDX_service_ratings_created_at_with_comment ON service_ratings (created_at DESC) WHERE comment IS NOT NULL, which
stays small since most ratings have no comment.
getSummary() aggregates in SQL: GROUP BY rating (at most 5 rows back), not a getMany() that pulls every
matching row into memory to sum/count in application code — this endpoint is unbounded by design (any date range,
any event), so aggregating in Postgres keeps it flat as ratings accumulate instead of degrading linearly.
Mobile comment capture (discuva-member, components/layout/attendance.tsx): the star-tap itself still submits
instantly with no comment (unchanged, one-tap). Once rated, an “Add a note” affordance appears — expands a small
textarea, and sending it re-calls submitRating with the same rating plus the comment (an upsert, so no separate
endpoint). Previously nothing in the UI ever sent a comment, so the admin moderation feed above was unreachable in
practice regardless of backend support.
Routes (member, JwtAuthGuard + @RequiresModule('service_ratings')): POST service-ratings (upsert),
GET service-ratings/mine?eventId=&serviceSlotId= — always returned comment on the full entity; the mobile widget
above now actually reads it, to restore an existing note on revisit.
Routes (admin, AdminGuard): GET admin/service-ratings/summary?eventId=&from=&to= (SERVICE_RATING_READ,
average + 1–5 distribution), GET admin/service-ratings/comments?page=&limit= (SERVICE_RATING_READ),
DELETE admin/service-ratings/:id (SERVICE_RATING_MODERATE, logs SERVICE_RATING_MODERATED).
Not audited: individual rating submissions — matches the existing judgment call on high-frequency member actions (same as Games answers and announcement reactions). Only moderation (deletion) is audited.
Routes prefix: /service-ratings, /admin/service-ratings
Volunteer Module
A self-service serving marketplace — admins post VolunteerOpportunity records (title, optional description,
department for admin-side categorization/reporting only — not access control, same convention as Game),
members browse and sign themselves up. Genuinely new interaction pattern for this codebase: every other
member-facing “assignment” (department membership, prayer roster, service-programme slots) is admin-assigned:
this is the first admin-posts-a-slot / member-claims-it flow.
Capacity enforcement: VolunteerOpportunity.confirmedCount is a denormalized counter (mirrors
PrayerMeeting.currentCapacity’s precedent), maintained inside a DB transaction that takes a pessimistic_write
lock on the opportunity row before checking capacity and incrementing/decrementing — this is the same
lock-then-check-then-mutate shape PrayerMeetingService.selectMeeting already uses, preventing two concurrent
sign-ups from both slipping past a capacity check that a non-transactional COUNT query could race. capacity: null
means unlimited.
Sign-up is an upsert, not insert-only: VolunteerSignup is unique on (opportunity, member). Cancelling
(status → CANCELLED) and re-signing-up flips the same row back to CONFIRMED rather than erroring on a duplicate
key or leaving orphaned rows — same “one row per (parent, member), status toggles” idiom as AnnouncementReaction
and ServiceRating, just with a two-state status instead of upsert-the-value.
No hard delete on opportunities: the admin “remove” action is PATCH .../cancel (→ status = CANCELLED,
audit-logged), not DELETE — mirrors this codebase’s general preference for deactivation over deletion
(see Member Deletion Policy) once a record may have dependent children (signups here).
Member list includes own status inline (GET volunteer-opportunities): each row carries
mySignupStatus: 'CONFIRMED' | 'CANCELLED' | null for the requesting member, computed via one extra batched query
against the returned page’s opportunity IDs — avoids a separate “my signups” round-trip just to render a
Sign-Up-vs-Cancel button per row. Only status = OPEN and date >= now opportunities are listed (past/closed/
cancelled opportunities don’t show in the browse list, though they remain visible to admins) — this is the
member-facing “browse open opportunities” feed, hit on every load, served by a composite
IDX_volunteer_opportunities_status_date ON volunteer_opportunities (status, date) index (in addition to the
original date-only index from the marketplace’s initial migration).
Mobile pagination (discuva-member, hooks/use-volunteer.ts): the backend route was already properly paginated
(page/limit); the mobile hook previously just hardcoded limit=50 and never advanced past page 1, silently
truncating the browse list once a church had more than 50 concurrently-open opportunities. Now tracks page/
totalPages and exposes goToPage, rendered as the same prev/next pager components/layout/sermons.tsx already
established for its own list — the standard pattern for a paginated list on this mobile app.
Routes (admin, AdminGuard + ModuleEnabledGuard): POST/GET/PATCH admin/volunteer-opportunities (/:id for
single-record routes), PATCH admin/volunteer-opportunities/:id/cancel,
GET admin/volunteer-opportunities/:id/signups (roster, CONFIRMED only) — all VOLUNTEER_READ/VOLUNTEER_WRITE.
VolunteerAdminController is also @RequiresModule('volunteering') (previously missing — every other admin
controller in this codebase pairs AdminGuard with ModuleEnabledGuard; without it, admins could manage
opportunities even with the volunteering module disabled in church settings, inconsistent with the member-facing
controller which already had the check).
Routes (member, JwtAuthGuard + @RequiresModule('volunteering')): GET volunteer-opportunities,
POST volunteer-opportunities/:id/signup, DELETE volunteer-opportunities/:id/signup — any authenticated
member/worker, no department/class gating (open to anyone, same “no access-control implication” stance as Game
and Sermon’s department/categorization fields).
Not audited: routine cancel-my-own-signup — matches the established judgment on high-frequency, low-stakes
member actions. VOLUNTEER_SIGNUP_CREATED (a commitment, worth a record unlike a passive reaction) and admin
actions (VOLUNTEER_OPPORTUNITY_CREATED/_UPDATED/_CANCELLED) are audited.
Routes prefix: /volunteer-opportunities, /admin/volunteer-opportunities
Member Directory Module (src/member-directory/)
Opt-in professional/business discoverability — members search each other by name, occupation, business, or skills (“who in the church is an accountant,” “does anyone run a catering business”) to drive collaboration. Deliberately scoped narrower than the original idea it came from: member-to-member chat and member-created interest groups were both explicitly deferred (chat as a genuine trust/safety decision to make deliberately later, not a technical default; interest groups deprioritized in favor of shipping the directory itself first).
Entity MemberDirectoryProfile (member_directory_profiles) — a separate entity from Member rather than
columns bolted onto it, so the whole feature stays cleanly removable via one migration if it’s ever pulled: 1:1 with
Member (member_id unique FK, ON DELETE CASCADE), occupation, businessName (kept separate — “I’m an
accountant” and “I run Adaeze’s Catering” are independent facts a member may want to share one, both, or neither
of), skills (free text, comma-separated — deliberately not a Postgres array column, so it stays searchable with
the same LOWER(...) LIKE convention as every other field here, no new query technique), bio (text).
Visibility is opt-in, no moderation step — isVisible (default false) mirrors Testimony.isPublic’s
“submitter’s own flag, no separate publish/approval step” precedent. showPhone/showEmail (both default false)
are deliberately separate from isVisible: surfacing contact info is a materially bigger privacy step than showing
an opted-in occupation/business/bio, so a member can be discoverable without exposing how to reach them directly.
Search (MemberDirectoryService.search) reuses this codebase’s existing search convention
(MemberService.getAll’s LOWER(field) LIKE LOWER(:s) pattern) across firstname/lastname/occupation/
businessName/skills, scoped to isVisible = true only. The response mapping omits phoneNumber/email per row
unless that row’s own showPhone/showEmail is true — the same “deliberately trim sensitive fields out of the
response” precedent MemberService.searchActiveMembersLite() already established for the admin check-in picker.
Paginated (Pagination Policy: member lists grow unboundedly).
Discoverability nudge: GET member-directory/me/completion returns whether a member’s own listing is visible
and has at least one of occupation/business/skills set (isDiscoverable) — the frontend uses this to show a prompt
encouraging the member to fill in their profile and opt in, directly serving the “get members to add their
professional/business details” goal rather than leaving the feature to sit empty by default.
Admin analytics (GET admin/member-directory/analytics, MEMBER_DIRECTORY_READ, read-only by design — admin
never edits an individual member’s listing, only views aggregates): total opted-in count and a profession
breakdown grouped by occupation, each with the list of members holding it, sorted by count descending. Never
returns phone/email regardless of a member’s own showPhone/showEmail choice — this is a church-wide statistics
view, not a directory lookup; an admin who needs to contact a member already has that via the regular member
record.
Gated on three independent axes:
KNOWN_MODULESkeymember_directory(required: false) — tenant admin’s own on/off toggle.PlanFeature.MEMBER_DIRECTORY— Pro plan only (migrationAddMemberDirectoryToProPlanappends it to the already-seededprorow’sfeaturesarray, same idiom asAddFormsToProPlan).KNOWN_ASSETSkeymember-directory-hero— lets a tenant admin upload a custom header image for the directory screen via the existing Appearance page (GET tenant/assets/catalogis rendered generically there, so no discuva-admin change was needed for this to appear).
Routes prefix: /member-directory (member/worker, JwtAuthGuard + ModuleEnabledGuard + PlanGuard),
/admin/member-directory (admin, AdminGuard + same module/plan guards).
Small Group Module (displayed to users as “Fellowships”)
Cell/home-fellowship tracking — the structural gap identified as the biggest single engagement-platform gap for
the target congregations (most run more on cell structure than department structure). Three entities: SmallGroup
(name unique, description, leader — a Member, deliberately not restricted to WorkerProfile/Admin since
cell leaders in this context aren’t necessarily on the worker roster — meetingDay/meetingLocation as free-text,
not an enum), SmallGroupMember (join table, unique on (group, member)), SmallGroupAttendance (unique on
(group, member, meetingDate) — re-recording the same date edits in place, same upsert idiom as
ServiceHeadcount).
Venue + online meeting support: SmallGroup also carries venue (Venue | null, ManyToOne, SET NULL on
delete — informational only, unlike EventConfig.defaultVenue’s RESTRICT, since losing the link on venue
deletion doesn’t break any live check-in flow), meetingFormat (MeetingFormatEnum, shared with EventConfig,
default IN_PERSON), and meetingLink (string | null). venue is added alongside, not instead of, the
existing free-text meetingLocation — most fellowships meet informally (e.g. a member’s home) with no registered
Venue row, so venue only covers the minority case of a fellowship meeting at an actual church-registered venue.
No cross-field validation is enforced server-side (unlike EventConfig’s IN_PERSON/ONLINE venue requirement) — a
fellowship is a much softer entity than a live check-in service, so an admin can freely leave both venue and
meetingLocation unset, or set either/both regardless of meetingFormat.
Membership is self-service, no approval step: POST small-groups/:id/join upserts-by-returning-existing
(mirrors VolunteerService.signUp’s “already confirmed → return the existing row” shape) rather than erroring on
a duplicate join — including under a concurrent double-tap: the initial existence check leaves a race window before
the insert, so join() also catches the (group, member) unique-constraint violation (Postgres 23505) and
re-fetches/returns the now-existing row instead of letting a raw DB conflict surface as a 500. DELETE small-groups/:id/leave is self-leave; admin-forced removal (DELETE admin/small-groups/:id/members/:memberId) is a
separate action, audited SMALL_GROUP_MEMBER_REMOVED (routine self-join/leave is not audited — matches the
established judgment on high-frequency member actions).
A group’s leader is not auto-enrolled as a member: create/update only set SmallGroup.leader, they never
insert a SmallGroupMember row for that person. getMembers()'s access check (assertIsGroupMember) therefore also
accepts the caller being group.leader.id, not just an existing membership row — otherwise a leader who never
separately “joined” their own group would be locked out of viewing its own roster (the mobile “Take Attendance” flow
calls this route first). assertIsGroupLeader() (used by recordAttendance) already worked this way; this just
brings roster access in line with it.
Index: listMine() (a member’s “My Fellowships” tab) filters small_group_members by member_id alone. The
table’s only prior index was IDX_small_group_members_group_id plus the (group_id, member_id) unique constraint —
both lead with group_id, so neither serves a member-only lookup. Added
IDX_small_group_members_member_id ON small_group_members (member_id).
Admin getRoster/getAttendanceHistory are now paginated: both previously returned every row for a group
unbounded — getAttendanceHistory in particular grows forever (one row per member per meeting, for the life of the
group), against the documented pagination policy. Both now take page/limit and return the standard
PaginationResponseDto shape ({ data, page, limit, totalCount, totalPages }) instead of a bare array — a breaking
response-shape change for GET admin/small-groups/:id/members and GET admin/small-groups/:id/attendance, updated
on the only consumer (discuva-admin/app/small-groups/page.tsx, hooks/use-small-groups.ts) to unwrap
res.data.data.data and render the shared PaginationBar per tab, same pattern the groups list itself already
used. Backed by two new composite indexes (ORDER BY meeting_date DESC/created_at ASC within a group, previously
only covered by the single-column group_id index):
IDX_small_group_attendance_group_id_meeting_date ON small_group_attendance (group_id, meeting_date DESC) and
IDX_small_group_members_group_id_created_at ON small_group_members (group_id, created_at ASC).
Leader-gated attendance-recording, not admin-gated: POST small-groups/:id/attendance sits under
JwtAuthGuard, not AdminGuard — a group leader need not be a worker or admin, so SmallGroupService’s private
assertIsGroupLeader() (mirrors assertHasCapability’s shape: throws ForbiddenException if
group.leader?.id !== callerId) is the only gate, independent of the admin permission system entirely. Body:
{ meetingDate, records: [{ memberId, status }] } — each record upserts against the unique
(group, member, meetingDate) constraint.
Full member roster requires group membership (GET small-groups/:id/members): gated by
assertIsGroupMember() (any current member, not leader-only) — lets a leader see who to mark attendance for and
lets ordinary members see their own group’s “family,” while still keeping the full roster invisible to a member
who’s browsing groups they haven’t joined yet. The browse list (GET small-groups) only exposes a memberCount,
not the roster itself.
No archive/cancel state (unlike VolunteerOpportunity): DELETE admin/small-groups/:id is a real delete —
groups are simpler organizational units without the same “keep history after the window closes” need a volunteer
opportunity has, so this follows Game’s full-CRUD precedent rather than VolunteerOpportunity’s
cancel-don’t-delete one.
Routes (admin, AdminGuard): POST/GET/PATCH/DELETE admin/small-groups (/:id for single-record routes),
GET admin/small-groups/:id/members?page=&limit=, DELETE admin/small-groups/:id/members/:memberId,
GET admin/small-groups/:id/attendance?page=&limit= — all SMALL_GROUP_READ/SMALL_GROUP_WRITE.
Routes (member, JwtAuthGuard + @RequiresModule('small_groups')): GET small-groups, GET small-groups/mine,
GET small-groups/:id, GET small-groups/:id/members (current members only), POST small-groups/:id/join,
DELETE small-groups/:id/leave, POST small-groups/:id/attendance (leader only, enforced in-service not by guard).
Routes prefix: /small-groups, /admin/small-groups
Platform Admin (Control Plane)
The SaaS control plane for the multi-tenant/freemium platform — see docs/MULTI_TENANT_MIGRATION.md for the full
design. Entirely separate from everything else in this document: it operates on public schema tables
(tenants, platform_admins, plans, subscriptions, communication_providers,
tenant_communication_provider_configs, giving_providers, tenant_giving_provider_configs,
payment_providers) that describe tenants themselves, never a tenant’s own
business data, and authenticates against a completely disjoint identity system (PlatformAdmin, not Member/Admin).
Auth: PlatformAdminGuard (validates the platform-admin-jwt Passport strategy, signed with
PLATFORM_ADMIN_JWT_SECRET — a different secret from JWT_SECRET, so a tenant token can never pass as a platform
one or vice versa). The whole controller is @Public() at the class level — this is load-bearing, not
decorative: JwtAuthGuard is a global APP_GUARD that runs on every route regardless of any @UseGuards() also
applied, and a platform admin never has a tenant JWT to satisfy it. @Public() skips only that global guard;
PlatformAdminGuard still independently protects every route except login.
Refresh session (POST /platform/auth/refresh): access tokens are short-lived (PLATFORM_ADMIN_JWT_EXPIRY_IN,
default 1h) and the frontend only ever keeps one in memory, never localStorage — so until this existed, a page
reload (or the access token simply expiring mid-session) logged every platform admin out unconditionally, with no
recovery besides a fresh password login. POST /platform/auth/login now also signs a refresh token
(PLATFORM_ADMIN_REFRESH_JWT_SECRET/_EXPIRY_IN, default 7d — deliberately its own secret, not shared with
either PLATFORM_ADMIN_JWT_SECRET or the tenant-side REFRESH_JWT_SECRET) and sets it as an httpOnly
platform_refresh_token cookie, scoped to path /v1/platform/auth and never returned in the JSON body.
PlatformAdminRefreshJwtStrategy reads that cookie name specifically — deliberately distinct from the tenant
member/admin refresh_token cookie, since both are set by the same shared api.discuva.org host across every
frontend origin, and reusing the same cookie name would let one clobber the other for any browser logged into both
discuva-admin and discuva-platform. refreshAccessToken() is stateless (no session/rotation-tracking table,
matching the access-token strategy’s own validateById re-check) — it just re-confirms the admin is still active
and issues a fresh token pair; the browser keeps sending the same refresh cookie until its own 7-day expiry.
POST /platform/auth/logout clears the cookie (previously logout was purely client-side, never told the backend at
all — the cookie would have just kept silently re-authenticating an ostensibly “logged out” session otherwise).
Permissions (PlatformAdminPermission, src/platform-admin/enum/). Every platform admin used to be binary —
isActive: true meant full access to every /platform/* route, false meant none. PlatformAdminRole (mirrors
tenant-side AdminRole exactly: name, description, permissions: string[]) now sits between them, and
PlatformAdminGuard does double duty as both the JWT-validating guard and the permission-checking guard (unlike
tenant-side, where a global JwtAuthGuard + a separate per-route AdminGuard split that job — /platform/* has no
global-guard equivalent to lean on, since every platform controller applies PlatformAdminGuard explicitly). A
platform admin’s permissions are loaded once, at JWT-validation time (PlatformAdminAuthService.validateById
eager-loads the platformAdminRole relation), not a second DB round-trip per request. @RequiresPlatformPermission(...)
mirrors tenant-side @RequiresPermission(...) and is applied per-route (or once at class level when every route in
a controller needs the same permission, e.g. PlatformAnalyticsController). Thirteen permissions across seven
groups — see PlatformAdminPermissionGroups for the exact list, used to render a grouped permission picker.
BROADCAST_WRITE (added alongside the tenant-broadcast capability below) needed a data migration, not just an
enum addition, to actually reach an already-seeded SuperAdmin role — PlatformAdminRole.permissions is a plain
text[] snapshotted once at row-creation time (DefaultPlatformAdminSeed never re-syncs an existing role against
the enum on later boots), so adding a new permission value does nothing for a platform admin whose role already
existed. See 1792371600000-GrantSuperAdminBroadcastPermission.ts.
Tenant health stats (GET /platform/tenants) include live memberCount/eventCount per tenant via
schema-qualified reads — cheap at the tens-to-low-hundreds tenant scale this product targets today, not a design
that scales to thousands of tenants without revisiting. impersonate issues a short-lived, access-token-only
JWT (no refresh token, no session record) signed directly rather than through the normal admin-login path — see
the code comment on PlatformTenantService.impersonateTenant for why. TenantMiddleware is wired into the live
request pipeline (§5 Multi-Tenant Request Scoping below), so that token routes to the correct tenant schema like
any other tenant-facing request.
Every method that hands a tenant back to a platform-admin caller (listTenants, createTenant, updateTenant,
suspendTenant) goes through one private toHealthShape() builder — previously createTenant/updateTenant/
suspendTenant returned the raw Tenant entity via tenantRepo.save(), leaking internal columns
(schemaName, clusterId, parentTenantId, shareDataWithParent/shareGivingWithParent) that have no business
being visible outside this service. All four routes now return the identical curated shape.
| Method | Route | Description |
|---|---|---|
| POST | /platform/auth/login |
Platform admin login — { email, password }, returns { accessToken, requiresPasswordChange } and sets the httpOnly platform_refresh_token cookie. |
| POST | /platform/auth/refresh |
PlatformAdminRefreshJwtAuthGuard (validates the refresh cookie, a separate check from PlatformAdminGuard). Returns a fresh { accessToken } and re-sets the refresh cookie. |
| POST | /platform/auth/logout |
Clears the refresh cookie. 204. |
| GET | /platform/tenants |
List all tenants — profile fields (logoUrl/tagline/address/supportEmail/currency/timezone), onboardingStatus, plan/subscription status, and live member/event counts. |
| POST | /platform/tenants |
Provisions a new tenant inline (TenantProvisioningService.provision(), not the queue self-serve /signup uses — see “Async Tenant Provisioning + Onboarding State Machine” above). Body has no password field, same as /signup’s SignupDto — the new admin gets a welcome email with a set-password link instead (see “Tenant Welcome / Set Password Flow” above). Returns the tenant already onboardingStatus: ACTIVE, same shape as GET /platform/tenants’ rows. |
| GET | /platform/tenants/:id/onboarding-events |
The platform-level onboarding audit trail for one tenant, oldest first — see “Async Tenant Provisioning + Onboarding State Machine” above. |
| PATCH | /platform/tenants/:id |
Update name, logo, tagline, address, support email, currency, timezone. Returns the same shape as GET /platform/tenants’ rows. |
| PATCH | /platform/tenants/:id/suspend |
{ suspend?: boolean }, default true — same route handles reactivation via { suspend: false }. Returns the same shape as GET /platform/tenants’ rows. |
| PATCH | /platform/tenants/:id/plan |
Manually change a tenant’s plan — comps, support fixes. Sets Subscription.status to active regardless of its prior value, so a canceled/past_due tenant regains access immediately rather than waiting on the next billing-provider webhook. Invalidates PlanGuard’s cached feature list. |
| PATCH | /platform/tenants/:id/discount |
Apply an internal comp — { discountType: 'percentage' | 'fixed_amount', discountValue, discountReason?, discountExpiresAt? }. Requires an existing subscription. Never touches checkout/a payment provider — see Billing & Checkout above. |
| DELETE | /platform/tenants/:id/discount |
Clear a tenant’s discount. |
| POST | /platform/tenants/:id/impersonate |
Issue a scoped support token for that tenant’s admin. |
| DELETE | /platform/tenants/:id |
TENANTS_DELETE permission (separate from TENANTS_WRITE). Permanently deletes a tenant — 409 unless onboardingStatus is PENDING, AWAITING_APPROVAL, or FAILED (an ACTIVE tenant must be suspended instead, never deleted here — this is also how a held signup gets rejected). Drops the tenant’s Postgres schema (DROP SCHEMA IF EXISTS ... CASCADE, a no-op if none was created) before removing the tenants row, which cascades onboarding events/subscriptions/etc. via existing FKs. |
| PATCH | /platform/tenants/:id/approve |
TENANTS_WRITE. Releases a self-serve signup held AWAITING_APPROVAL — 409 otherwise. Reconstructs the provisioning job from pendingSignupParams and enqueues it (still async, same as any self-serve signup). See “Manual Approval Gate for Self-Serve Signups” above. |
| GET | /platform/plans |
List plan rows (every currency/interval variant of every tier). |
| POST | /platform/plans |
Create a plan row — tierKey and billingInterval required, group it with sibling currency/interval variants. See “Multi-currency, multi-interval tiers” under Billing & Checkout above. |
| PATCH | /platform/plans/:id |
Edit a plan row’s price/currency/billingInterval/features/featureLimits/tierKey. 400 if changing currency or billingInterval on a row that already has a billingProviderPriceId — see “Multi-currency, multi-interval tiers” above. |
| GET | /platform/capabilities |
[{ key, label }] — every valid features/featureLimits key (every KNOWN_MODULES entry plus the 4 module-less PlanFeature values), labeled for the Plans page’s checkbox list. See “Every toggleable module is also a plan-assignable capability” above. |
| GET | /platform/subscriptions |
List all subscriptions — spot past_due churn risk. |
| GET | /platform/communication-providers |
List platform-wide registered SMS/email providers. |
| POST | /platform/communication-providers |
Register a new provider — { id, channel, name }. |
| PATCH | /platform/communication-providers/:id |
{ isActive: boolean } — activate/deactivate a provider in the platform-wide catalog. See “Communication Providers: deactivation has real consequences” below for what this actually does to a tenant already using the provider. |
| GET | /platform/tenants/:id/communication-providers |
A tenant’s active provider per channel — never the raw encrypted credentials. |
| GET | /platform/analytics/overview|/growth|/revenue|/engagement|/churn|/adoption |
Cross-tenant business metrics — see “Platform Analytics” below. |
| GET | /platform/tenants/:id/billing-sessions |
This tenant’s checkout session history, newest first. |
| POST | /platform/billing-sessions/:sessionId/refund |
Refund a completed checkout via the original provider — see Billing & Checkout above. |
| GET | /platform/admin-roles |
List platform admin roles. |
| GET | /platform/admin-roles/:id |
Get one platform admin role. |
| POST | /platform/admin-roles |
Create a role — { name, description?, permissions: PlatformAdminPermission[] }. |
| PATCH | /platform/admin-roles/:id |
Edit a role’s name/description/permissions. |
| DELETE | /platform/admin-roles/:id |
Delete a role — 400 if any active platform admin is still assigned to it. |
| GET | /platform/admins |
List platform admins with their role. |
| GET | /platform/admins/me |
The calling platform admin’s own record + permissions — no permission requirement beyond a valid token. |
| GET | /platform/admins/:id |
Get one platform admin. |
| POST | /platform/admins |
Onboard a new platform admin — { email, platformAdminRoleId }. No password field — the new admin gets a welcome email with a set-password link instead (see below). |
| PATCH | /platform/admins/:id |
Change role and/or isActive. 403s if id is the caller’s own — see below. |
| POST | /platform/auth/forgot-password |
Public, rate-limited (5/min). Request a password-reset OTP for a platform admin. |
| POST | /platform/auth/reset-password |
Public, rate-limited (5/min). Verify the OTP and set a new password — also how a newly-onboarded admin sets their initial one. |
| POST | /platform/broadcast |
{ subject, message } (plain text, not HTML) — one email to every active tenant’s oldest active admin. See “Tenant Broadcasts” below. |
| GET | /platform/settings |
List platform-wide settings (grace period + the five upload-size limits) — see “Platform Settings” below. |
| PATCH | /platform/settings/:key |
{ value: number } — edit a platform-wide setting live, no redeploy. BILLING_WRITE. |
Platform Settings
A generic, platform-wide (not per-tenant) key/value settings store — the platform-admin equivalent of the tenant-side
Church Settings module above, but living in public schema with no tenant dimension: new PlatformSetting entity
(key unique, value: jsonb), read/written through PlatformSettingsService, same short-TTL cache pattern as
ChurchSettingsService/ReminderSettingsService. KNOWN_PLATFORM_SETTINGS
(src/platform-admin/constant/known-platform-settings.constant.ts) is the whitelist — each entry now carries
min/max alongside label/unit/defaultValue, enforced server-side in PlatformSettingsService.upsert()
(400 if out of range) since these vary per key and can’t all share one class-validator bound. GET/PATCH /platform/settings responses include min/max too, so the frontend renders the right bounds per setting instead
of a hardcoded range.
Consumer 1 — subscription grace period: SubscriptionLapseScheduler used to read GRACE_PERIOD_DAYS from
an env var once at boot (a single global value, requiring a redeploy to change). It now calls
PlatformSettingsService.getSubscriptionGracePeriodDays() once per daily run instead — still a single global value
(not per-tenant: this is billing/revenue policy Discuva sets uniformly, not a per-church preference — a deliberate
distinction from the tenant-facing Reminder Settings module above, which covers per-church operational preferences).
The GRACE_PERIOD_DAYS env var and its Joi entry have been removed; any deployed value for it is now inert.
Consumer 2 — upload size limits: MAX_LOGO_UPLOAD_MB, MAX_AVATAR_UPLOAD_MB, MAX_CLASS_MATERIAL_UPLOAD_MB,
MAX_FINANCE_PROOF_UPLOAD_MB, MAX_FORM_ATTACHMENT_UPLOAD_MB, MAX_PAGE_IMAGE_UPLOAD_MB — stored in MB (not bytes, since that’s what a platform admin actually types into
the settings form), read via PlatformSettingsService.getMaxUploadBytes(key) which converts to bytes. These
replace the MAX_LOGO_UPLOAD_BYTES/MAX_AVATAR_UPLOAD_BYTES/MAX_CLASS_MATERIAL_UPLOAD_BYTES/
MAX_FINANCE_PROOF_UPLOAD_BYTES env vars entirely (removed from env.validation.ts) — MAX_FILE_UPLOAD_BYTES
remains an env var, unaffected, since it’s the fallback for routes with no dedicated category (incident report
photos, member bulk-import).
Fixed: tenant-scoped cache namespacing bug. CacheService.get/set/del always namespace by whatever tenant
(if any) is in CLS context — correct for genuinely per-tenant data, but PlatformSettingsService’s values aren’t
tenant-specific. A platform-admin write has no tenant context (scopes to tenant:global:...), but
getMaxUploadBytes() is called from a real tenant-scoped upload request, so it was caching under
tenant:<that-tenant's-id>:... — a platform-admin change never invalidated it, leaving each tenant serving a stale
limit for up to the 300s cache TTL after every change. CacheService now exposes getGlobal/setGlobal/
delGlobal (a genuinely separate global:... key namespace, not the tenant-scoped methods’ coincidental
'global' fallback), and PlatformSettingsService uses them for every cache call, including
getSubscriptionGracePeriodDays() — which happened to dodge this bug only because SubscriptionLapseScheduler
calls it before entering any per-tenant loop, not because it was actually correct.
Enforcing a live limit is a real constraint Multer doesn’t support natively: limits.fileSize has to be a static
number known when the route is decorated, it can’t await a DB/cache read per request. DynamicLimitedFileInterceptor
(src/utility/interceptors/dynamic-limited-file.interceptor.ts) resolves this by letting Multer parse against a
generous, non-configurable hard ceiling (UPLOAD_HARD_CEILING_BYTES, always ≥ the setting’s max) as a safety net,
then checking the actual parsed file’s size against the live platform-configured limit inside intercept()
afterward, rejecting with an accurately-labeled PayloadTooLargeException if it’s over. A file between the live
limit and the hard ceiling is still fully buffered before being rejected — an accepted tradeoff given how small
these ceilings are (tens of MB), rather than reimplementing Multer’s own streaming internals. TenantInfoController
(logo + appearance assets), MemberController (me/photo), ClassesController (materials/upload), and
FinanceWorkerController (requests attachment) all use this interceptor now instead of the static
LimitedFileInterceptor.
PlatformAdminModule is now @Global() so PlatformSettingsService can be injected into
DynamicLimitedFileInterceptor from any consuming module (TenantModule, MemberModule, ClassesModule,
FinanceRequestModule) without each needing an explicit import path — same reasoning UtilityModule documents for
its own @Global() (guards/interceptors resolve dependencies via the consuming controller’s module, not the
declaring module).
Consumer 3 — attendance distance-check default: ENFORCE_DISTANCE_CHECK_DEFAULT — the first boolean
PlatformSetting (every prior one was a plain number). KnownPlatformSetting gained an optional type: 'number' | 'boolean' field purely as a rendering hint (still stored/transmitted as 0/1, no new column or shape) — the
settings page renders a toggle instead of a number input when type === 'boolean'. See “Attendance Distance Check
Setting” in the Attendance Module section above for the full per-tenant-override picture this platform default sits
underneath.
Consumer 4 — social media draft retention: SOCIAL_MEDIA_DRAFT_RETENTION_DAYS (default 30) — read by
SocialMediaRetentionScheduler’s daily sweep (see Social Media Module above). No dedicated frontend work was
needed for this one: /billing-settings already renders every KNOWN_PLATFORM_SETTINGS entry generically from the
GET /platform/settings response, so a new key just appears.
Consumer 5 — self-serve signup approval gate: SELF_SERVE_REQUIRES_APPROVAL (default off) — read by
SignupController.signup() via PlatformSettingsService.getSelfServeRequiresApproval(), awaited since it gates
request flow (this codebase’s Redis convention for a get() that decides what a request does, not merely renders).
See “Manual Approval Gate for Self-Serve Signups” above for the full flow. Another boolean, same rendering-hint
type: 'boolean' /billing-settings toggle as ENFORCE_DISTANCE_CHECK_DEFAULT — no dedicated frontend work needed
here either.
Retired: SOCIAL_MEDIA_ENABLED (formerly Consumer 5 here — a boolean, all-tenants-at-once composer readiness
gate). Removed once Tenant.moduleOverrides shipped (see the Social Media Module and Tenant Module sections
above) — the Social Media Rollout control (single toggle + searchable multi-select, PUT /platform/social-media/rollout) replaces its job with real per-church granularity and actual backend enforcement,
which this setting never had (it only ever gated one frontend check, never the API itself). GET /social-media/platform-enabled still exists and discuva-admin still calls it the same way — see the Social Media
Module section above for what it checks now instead.
Frontend: discuva-platform’s /billing-settings page (own layout.tsx, same “every new route needs one”
convention, now titled “Platform Settings” in-page and in the sidebar since it’s no longer billing-only), gated by
billing:read/billing:write (reusing the existing permission pair /giving-providers and /payment-providers
already use — no new permission introduced for the upload-limit settings, they’re gated the same as every other
platform-wide setting on this page). The per-row number input’s min/max now come from each setting’s own API
response instead of a hardcoded 0–365; a boolean-typed setting renders a toggle switch instead of a number input.
Routes prefix: /platform
Tenant Broadcasts (TenantBroadcastService, added 2026-08)
Sends one email to every active tenant’s oldest active admin — used both as a direct platform-admin action
(POST /platform/broadcast, discuva-platform’s “Broadcast” nav page) and internally by other services that need
to notify every tenant about something platform-wide (first consumer: Communication Provider deactivation, below).
Never a single batched to: [...] call. Confirmed live in EmailProcessor: an array to produces one shared,
mutually-visible To: header (Array.isArray(to) ? to.join(', ') : to) — sending one email to every tenant’s
admin that way would leak every church admin’s email address to every other church admin. TenantBroadcastService
instead uses forEachActiveTenant (already proven by SubscriptionLapseScheduler) to re-enter each tenant’s own
schema and queue one individual EmailQueueService.queueEmail() call per tenant.
Only the tenant’s oldest active admin is notified, same “one primary contact” convention
SubscriptionLapseScheduler already established for platform-initiated notices — not every admin the tenant has.
Two entry points on the service, one plain-text and one raw-HTML:
broadcastPlainTextToAllTenantAdmins(subject, message)— whatPOST /platform/broadcastactually calls. Each non-blank line ofmessagebecomes its own<p>, HTML-escaped first. A platform admin typing into a form textarea should never be able to inject arbitrary markup/scripts into an email reaching every church on the platform at once.broadcastToAllTenantAdmins(subject, html)— the lower-level primitive, for internal callers that need real markup (e.g. a provider-outage notice with a link). Every otherqueueEmailcall site in this codebase passes raw HTML directly; this one is no different, it’s only the plain-text entry point above that restricts it.
Result shape, distinct from forEachActiveTenant’s own { succeeded, failed }: { sent, skipped, failed } —
skipped (a tenant with no active admin on file) is tracked separately from failed (the tenant callback itself
threw), since neither means the same thing operationally.
Permission: BROADCAST_WRITE, deliberately its own permission rather than folded into an existing one — same
“independently grantable, bigger blast radius than it looks” reasoning as TENANTS_IMPERSONATE. See the
migration note earlier in this section for why an already-seeded SuperAdmin role needed a data migration, not
just the enum addition, to actually gain this permission.
Platform Admin Management (/platform/admins, /platform/admin-roles)
Onboarding/permission management for platform admins themselves — previously the only way to create one was a
hand-written SQL insert (there was nothing else to onboard multiple platform admins with, and no way to scope any
of them below full access). PlatformAdminManagementService (users) and PlatformAdminRoleService (roles) mirror
AdminService/AdminRoleService’s tenant-side shape closely, with two differences: no audit-log tie-in (tenant-side
logs into a tenant-scoped audit_logs table this control-plane has no equivalent of, and platform-admin actions
aren’t audited anywhere else in this codebase either), and platform admins have no underlying Member — creating
one is a single step (email + platformAdminRoleId), not tenant-side’s separate “create a member” → “grant them
admin” two-step flow.
POST /platform/admins takes no password — the onboarding admin isn’t the one logging in as the new admin, so
there’s nobody present to choose one. PlatformAdminManagementService.create() generates a random password
internally (never revealed to anyone, changedPassword: false), a 6-digit OTP stored in
platform_admin_password_reset_otps (48-hour expiry — same tradeoff as the tenant-welcome flow, offset by rate-
limiting POST /platform/auth/reset-password), and emails the new admin a platform-admin-welcome template with a
{PLATFORM_LOGIN_URL}/set-password?email=...&otp=... link — the discuva-platform equivalent of the tenant
onboarding flow above, down to reusing the same OTP-verify-and-set-password shape
(PlatformAdminAuthService.forgotPassword/resetPassword, its own OTP table rather than tenant-side’s
password_reset_otps since PlatformAdmin and Member are deliberately disjoint identity systems). Login also now
returns requiresPasswordChange: !admin.changedPassword, mirroring the tenant-side login response shape, though
nothing currently enforces it in the frontend — a random, unrevealed password can’t be logged in with in practice,
so the flag is informational/defense-in-depth, not an enforced gate.
PATCH /platform/admins/:id blocks an admin from modifying their own record entirely (role or isActive) —
stricter than tenant-side, whose AdminService.update() does the same self-block but revoke() is a separate,
unguarded action. Combined here into one endpoint, so the self-block covers both. PlatformAdminRoleService.delete()
mirrors tenant-side’s exact business rule: blocked with 400 while any active admin is still assigned that role.
Bootstrap script — the first platform admin. Mirrors src/seed.ts/DefaultAdminSeed exactly:
DefaultPlatformAdminSeed (src/platform-admin/seed/), run via npm run seed:platform-admin
(node dist/seed-platform-admin in prod), reads DEFAULT_PLATFORM_ADMIN_EMAIL/DEFAULT_PLATFORM_ADMIN_PASSWORD_HASH
(generate the hash with the same npm run hash:password — already fully generic, no platform-specific variant
needed), skips if either is unset or if any platform_admins row already exists (idempotent — safe to leave in a
deploy pipeline), and seeds the admin with a find-or-create Platform Super Admin role holding every
PlatformAdminPermission. The AddPlatformAdminRoles migration also seeds this same role directly (originally
named SuperAdmin, see rename note below) and backfills any pre-migration platform_admins row onto it — the seed
script’s findOrCreateSuperAdmin() is what a fresh environment without that migration history hits.
Renamed from SuperAdmin to Platform Super Admin (1793044800000-RenamePlatformSuperAdminRole.ts): the
tenant-side AdminRole (one church, seeded by AdminRoleService.findOrCreateSuperAdmin/
TenantProvisioningService.seedTenantAdmin) and this platform-side PlatformAdminRole were both independently
named the literal string SuperAdmin — indistinguishable by name alone across two very different scopes (one
church vs. every tenant plus billing/impersonation). Renamed the platform side only, since it’s a single
control-plane table with few rows, versus the tenant-side name every existing church’s primary admin already sees.
findOrCreateSuperAdmin() is self-healing: it looks for Platform Super Admin first, then falls back to renaming
a legacy SuperAdmin row in place if the migration hasn’t run yet in that environment, rather than ever creating a
duplicate.
Platform Analytics (GET /platform/analytics/*)
Cross-tenant business metrics for whoever operates the platform itself — “how is the whole business doing,” not any
one church’s data. PlatformAnalyticsService (src/platform-admin/service/platform-analytics.service.ts) is
deliberately every method a live query, no new aggregation table or cron: tenant_rollups,
billing_checkout_sessions, and subscriptions are already small (one row per tenant, or one row per checkout) at
any realistic tenant count, so a SUM/GROUP BY at request time is cheap. Revisit only if tenant count genuinely
grows large enough to matter.
Trend bucketing (growth/revenue/churn): raw timestamped rows are fetched within a bounded window
(?months=, default 12, max 36) and bucketed in-memory by ?period=daily|weekly|monthly (default monthly) —
same in-JS-bucketing convention ServiceHeadcountService’s own trend endpoint already established, not a SQL
date_trunc. Weekly buckets label by the Sunday of that week; monthly buckets label YYYY-MM.
What’s a real trend vs. a snapshot: tenant signups (growth), revenue (revenue), and cancellations (churn)
are genuine time series — tenants.createdAt, billing_checkout_sessions.completedAt, and
subscriptions.canceledAt (added this pass — see below) are all real timestamps. Active-vs-suspended tenant counts
are not a trend — tenants.isActive is a plain boolean with no historical event log behind it, so growth
reports it as a current snapshot (currentActiveTenants/currentSuspendedTenants), not a fabricated time series.
Subscription.canceledAt (new column, src/migrations/1791072000000-AddSubscriptionCanceledAt.ts): set exactly
once, by CheckoutService.applySubscriptionCanceled(). Added specifically because updatedAt can’t be trusted for
“when this subscription was canceled” — it changes on any field update, not just a cancellation.
| Method | Route | Description |
|---|---|---|
| GET | /platform/analytics/overview |
{ totalTenants, activeTenants, suspendedTenants, totalMembersPlatformWide, subscriptionsByPlan[], mrrByCurrency: [{currency, mrrCents}] } — headline numbers. mrrByCurrency replaced a single blended mrrCents figure (breaking change) once plans could be priced in more than one currency |
| GET | /platform/analytics/growth |
?period=&months= — { period, signups: [{periodLabel, count}], currentActiveTenants, currentSuspendedTenants } |
| GET | /platform/analytics/revenue |
?period=&months= — { period, mrrByCurrency: [{currency, mrrCents}], revenueByProvider: [{provider, totalCents}], trend: [{periodLabel, subscriptionRevenueCents, totalCents}] } — only completed BillingCheckoutSession rows count (subscriptions only — wallet_topup no longer exists as a checkout type) |
| GET | /platform/analytics/engagement |
{ totalMembers, averageAttendanceRate, totalGiving, tenantsWithRollup, tenantsMissingRollup, oldestComputedAt, newestComputedAt } — sourced entirely from tenant_rollups (§Branch Hierarchy); oldest/newestComputedAt signal staleness since the rollup cron runs once daily |
| GET | /platform/analytics/churn |
?period=&months= — { period, currentlyCanceled, currentlyPastDue, trend: [{periodLabel, canceledCount}] } |
| GET | /platform/analytics/adoption |
{ smsAdoption: {byokCount, totalTenants, ratePercent}, emailAdoption: {...}, planDistribution: [{planId, planName, count}] } — BYOK adoption counts distinct tenants with an active TenantCommunicationProviderConfig per channel |
MRR calculation (overview and revenue): SUM(plan.priceCents) over every ACTIVE subscription, joined to its
plan — a tenant on the free plan contributes 0 naturally, no special-casing needed. This is current recurring
revenue (what’s active right now), distinct from revenue’s trend, which is realized revenue from completed
checkouts over time — the two can disagree (e.g. a subscription active today whose original checkout completed
outside the requested ?months= window).
6. API Endpoints Quick Reference
All routes are prefixed with
/v1/via NestJS URI versioning (defaultVersion: '1'). For example,POST /auth/loginis accessed asPOST /v1/auth/login. Future endpoint versions can be declared with@Version('2')at the controller or method level without affecting existing routes.
| Method | Route | Role | Description |
|---|---|---|---|
| GET | /health | Public | Liveness check used by Fly every 30s — probes Redis only (no database query, so it can’t stop Neon scaling to zero); 503 if Redis is unreachable. Exempt from rate limiting (@SkipThrottle). |
| GET | /health/deep | Public | Also probes the database (wakes it if scaled to zero); 503 listing whatever is unreachable. For manual checks or a low-frequency monitor. |
| POST | /auth/signup | Public | Register new member (server generates temp password; emailed to user) |
| POST | /auth/login | Public | Mobile app login — requires deviceId; enforces one-device-per-account lock |
| POST | /auth/admin-login | Public | Admin portal login — verifies active Admin record; no device check |
| POST | /auth/refresh | Public | Exchange refresh token |
| POST | /auth/logout | Any | Invalidate session |
| GET | /auth/me | Any | Own profile. Includes isHod: boolean — true if the authenticated member has a row in department_leads; clergy: {title: {id, name}, canReviewFeedback} | null; and isTrainee: boolean — mirrors workerProfile.isTrainee (false for non-workers). Clients should fetch this once on load to drive HOD/trainee-gated UI. |
| POST | /auth/change-password | Any | Change password (required when requires_password_change is true) |
| POST | /auth/email-change/request | Any (JwtAuthGuard) | Body { newEmail } — sends a 6-digit OTP to the new address; rate-limited; 409 if already in use by another member |
| POST | /auth/email-change/confirm | Any (JwtAuthGuard) | Body { otp } — verifies OTP, updates own email, sends a confirmation email |
| POST | /auth/forgot-password | Public | Request OTP reset code (rate-limited) |
| POST | /auth/reset-password | Public | Verify OTP and set new password; invalidates current session |
| POST | /auth/device-reset/request | Public | Self-service device reset — rate-limited; issues OTP to registered email; locks in newDeviceId at request |
| POST | /auth/device-reset/verify | Public | Verify OTP and swap deviceId to newDeviceId; invalidates all active sessions |
| POST | /auth/webauthn/login/options | Public | Biometric login, step 1 — no email needed (allowCredentials omitted); returns { challengeId, options }; IP-throttled (10/min) |
| POST | /auth/webauthn/login/verify | Public | Biometric login, step 2 — body { challengeId, response }; resolves the member from the credential and issues tokens via the same path password login uses |
| POST | /auth/webauthn/register/options | Any (JwtAuthGuard) | Enroll a new device, step 1 — returns resident-key (residentKey: 'required') registration options for the calling member |
| POST | /auth/webauthn/register/verify | Any (JwtAuthGuard) | Enroll a new device, step 2 — body is the browser’s RegistrationResponseJSON; stores the new credential, 204 on success |
| GET | /auth/webauthn/credentials | Any (JwtAuthGuard) | List the caller’s own registered devices — { id, deviceName, createdAt, lastUsedAt }[], never the credential id/public key |
| DELETE | /auth/webauthn/credentials/:id | Any (JwtAuthGuard) | Remove one of the caller’s own devices; 404 if it doesn’t belong to them; 204 on success |
| PATCH | /members/me | Any (JwtAuthGuard) | Self-service profile edit: firstname, lastname, phoneNumber, gender, birthDay, birthMonth, birthYear, maritalStatus, dateJoinedChurch, yearBornAgain, yearBaptized, baptizedWithHolyGhost; workers can also set their own profession and yearJoinedWorkforce (ignored for non-workers; a future year is rejected) (excludes email) |
| POST | /members/me/serve-interest | Any (JwtAuthGuard) | Record “I’d like to serve” (serveInterestAt); 400 for workers |
| DELETE | /members/me/serve-interest | Any (JwtAuthGuard) | Withdraw the serve request |
| DELETE | /members/:id/serve-interest | AdminGuard (MEMBERS_WRITE) | Dismiss a member’s serve request (declined); idempotent, audited. The member can request again |
| POST | /members/me/photo | Any (JwtAuthGuard) | Upload/replace own profile photo — multipart field photo, image mimetypes only, 3MB limit |
| DELETE | /members/me/photo | Any (JwtAuthGuard) | Remove own profile photo |
| DELETE | /members/:id/photo | AdminGuard (MEMBERS_WRITE) | Moderation — clear a member’s profile photo |
| GET | /members?page=&limit=&role=&search=&wantsToServe= | AdminGuard (MEMBERS_READ) | List members — filterable by role; search matches firstname, lastname, email, or phone (case-insensitive); wantsToServe=true returns only active members with a pending serve request |
| POST | /members | AdminGuard (MEMBERS_WRITE) | Create a plain MEMBER account directly (body: SignupDto) — shares signup()'s temp-password/forced-change-password flow; audit-logged as MEMBER_CREATED_BY_ADMIN |
| GET | /members/workers | AdminGuard (MEMBERS_READ) | List workers (filterable by status) |
| GET | /members/:id | AdminGuard (MEMBERS_READ) | Get member by ID |
| PATCH | /members/:id | AdminGuard (MEMBERS_WRITE) | Update member details |
| POST | /members/bulk-promote | AdminGuard (MEMBERS_WRITE) | Bulk promote members to workers; returns { promoted, skipped, failures: [{ memberId, reason }] } |
| POST | /members/:id/promote | AdminGuard (MEMBERS_WRITE) | Promote member to worker — reactivates a prior INACTIVE WorkerProfile (resumes department/progress) if one exists, else creates new. Only departmentId is required; profession/yearJoinedWorkforce are optional (omit them rather than sending empty strings) and the worker is prompted in the member app to add them |
| POST | /members/:id/revoke-worker | AdminGuard (MEMBERS_WRITE) | Remove worker role (deactivates WorkerProfile to INACTIVE; row is kept, not deleted) |
| POST | /members/:id/demote-trainee | AdminGuard (MEMBERS_WRITE) | Demote a trainee worker to MEMBER (keeps WorkerProfile as INACTIVE history; 400 if not a trainee) |
| PATCH | /members/:id/worker-profile | AdminGuard (MEMBERS_WRITE) | Update worker profile (incl. isTrainee) |
| PATCH | /members/:id/status | AdminGuard (MEMBERS_WRITE) | Activate/deactivate member |
| POST | /members/:id/reset-password | AdminGuard (MEMBERS_WRITE) | Reset & email new password |
| DELETE | /members/:id/device | AdminGuard (MEMBERS_WRITE) | Purge device lock; invalidates all active sessions |
| POST | /members/:id/clergy | AdminGuard (MEMBERS_WRITE) | Assign clergy designation, body { clergyTitleId }; 409 if already clergy, 404 if the title is unknown |
| PATCH | /members/:id/clergy | AdminGuard (MEMBERS_WRITE) | Change clergy title, body { clergyTitleId }; 404 if not clergy, or if the title is unknown |
| DELETE | /members/:id/clergy | AdminGuard (MEMBERS_WRITE) | Remove clergy designation; 404 if not clergy; returns 204 |
| POST | /members/:id/spouse | AdminGuard (MEMBERS_WRITE) | Link two members as spouses (symmetric), body { spouseId }; 400 if either side already has a spouse or spouseId is the member’s own id |
| DELETE | /members/:id/spouse | AdminGuard (MEMBERS_WRITE) | Unlink spouse on both sides; 400 if no spouse is linked; returns 204 |
| PATCH | /members/:id/clergy/review-access | AdminGuard (MEMBERS_WRITE) | Grant/revoke Pastor Feedback review access, body { canReviewFeedback }, independent of title; 404 if not clergy |
| GET | /clergy-titles | Public | Tenant’s clergy-title catalog, see ClergyTitle above |
| GET | /clergy-titles/:id | Public | Single clergy title |
| POST | /clergy-titles | AdminGuard (MEMBERS_WRITE) | Create a clergy title, body { name, description? }; 400 if name in use |
| PATCH | /clergy-titles/:id | AdminGuard (MEMBERS_WRITE) | Update a clergy title |
| DELETE | /clergy-titles/:id | AdminGuard (MEMBERS_WRITE) | 400 if any clergy member is still assigned to it |
| GET | /members/bulk-import/template | AdminGuard (MEMBERS_WRITE) | Streams a .xlsx bulk-import template |
| POST | /members/bulk-import/preview | AdminGuard (MEMBERS_WRITE) | Multipart file upload (5 MB cap); validates every row, persists a MemberImportJob + rows, returns { ...job, rows } |
| GET | /members/bulk-import/:jobId | AdminGuard (MEMBERS_WRITE) | Refetch a previously-previewed import job and its rows |
| POST | /members/bulk-import/:jobId/commit | AdminGuard (MEMBERS_WRITE) | Create a Member (+ WorkerProfile if department was filled) for every valid row; returns { createdCount, failedRows } |
| GET | /admin/roles | AdminGuard (ADMIN_READ) | List admin roles |
| GET | /admin/roles/:id | AdminGuard (ADMIN_READ) | Get admin role by ID |
| POST | /admin/roles | AdminGuard (ADMIN_WRITE) | Create admin role |
| PATCH | /admin/roles/:id | AdminGuard (ADMIN_WRITE) | Update admin role |
| DELETE | /admin/roles/:id | AdminGuard (ADMIN_WRITE) | Delete admin role |
| GET | /admin/users | AdminGuard (ADMIN_READ) | List admin users |
| GET | /admin/users/me | AdminGuard | Own admin profile (includes favouritePages) |
| PUT | /admin/users/me/favourite-pages | AdminGuard (any admin) | Replace own pinned pages. Body { pages: string[] } — up to 12 admin-portal paths (/members, /finances/external-payees), order kept, duplicates dropped. Returns { favouritePages } |
| GET | /admin/users/:id | AdminGuard (ADMIN_READ) | Get admin user by ID |
| POST | /admin/users | AdminGuard (ADMIN_WRITE) | Grant admin access to a member |
| PATCH | /admin/users/:id | AdminGuard (ADMIN_WRITE) | Update admin user role/status |
| POST | /admin/users/:id/revoke | AdminGuard (ADMIN_WRITE) | Revoke admin access |
| GET | /admin/audit-logs | AdminGuard (AUDIT_READ) | Paginated audit log; filterable by action, actorId, targetId, dateFrom, dateTo |
| POST | /attendances/checkin | Any | Check in to a service slot (workers must include location when the resolved slot format is IN_PERSON; not required for ONLINE; one record per event per member) |
| GET | /attendances/me/distance-check | Any (JwtAuthGuard) | { enabled, isPlatformDefault } — member-readable mirror of the admin distance-check setting below, so the member app can skip its own client-side distance block when enforcement is off |
| GET | /attendances/my-history | Any | Own attendance records |
| GET | /attendances/my-summary | Any | Own lifetime rate/streak, computed in SQL over full history (not just the current page) — { totalCount, presentCount, attendanceRatePercentage, lastCheckedInDate, attendanceStreak } |
| GET | /attendances/history | AdminGuard (ATTENDANCE_READ) | All attendance records; query: page, limit, memberId, slotId, status, dateFrom, dateTo, search (ILIKE on firstname, lastname, email), role (MEMBER|WORKER — filters Attendance.roleAtCheckin, the role snapshotted at check-in time, not the member’s current role) |
| POST | /attendances/export-email | AdminGuard (ATTENDANCE_READ) | Email the currently-filtered attendance history as an .xlsx attachment (body: recipientEmail?, memberId?, slotId?, status?, dateFrom?, dateTo?, search?, role?). recipientEmail defaults to the requesting admin’s own email. One-off only — not a recurring/scheduled report. Logs REPORT_EXPORTED. |
| GET | /attendances/history/department?slotId=&page=&limit= | WORKER | Paginated department attendance for a slot (scoped to caller’s own department via lead role); page defaults to 1, limit to 20 |
| GET | /attendances/department/event/:eventId | WORKER | Worker attendance for all slots of an event (scoped to caller’s own department via lead role) |
| GET | /attendances/summary/slot/:slotId | AdminGuard (ATTENDANCE_READ) | Status counts for a slot |
| GET | /attendances/leaderboard | AdminGuard (ATTENDANCE_READ) | Top workers by attendance |
| PATCH | /attendances/:id/correct | AdminGuard (ATTENDANCE_WRITE) | Admin correction of an attendance record status |
| GET | /attendances/at-risk?minAbsences=&from=&to=&page=&limit= | AdminGuard (ATTENDANCE_READ) | Members with ≥ N ABSENT records in range; returns absenceCount, lastSeenAt, hasOpenFollowUpTask |
| POST | /attendances/admin/mark | AdminGuard (ATTENDANCE_WRITE) | Create/backfill an attendance record for any member+event — no-phone check-in and streak restore, body { memberId, serviceSlotId, status } |
| POST | /attendances/department/mark | JwtAuthGuard (Admin-department worker) | Same action, mobile-reachable for Admin-department front-desk workers; same body |
| GET | /attendances/department/search-members?q= | JwtAuthGuard (Admin-department worker) | Narrow member lookup (≤10 results, id/firstname/lastname/role only) backing the mobile check-in picker |
| POST | /attendances/online-confirm | JwtAuthGuard (any authenticated member) | Confirm online attendance for an event (updates ABSENT → ATTENDED_ONLINE within window) |
| POST | /follow-up/first-timers | WORKER (FOLLOW_UP dept) | Register a first-timer (auto-creates FollowUpTask via round-robin) |
| POST | /follow-up/first-timers/:id/link-convert | WORKER (FOLLOW_UP dept) | Confirm a suggested outreach convert { convertId } |
| POST | /follow-up/first-timers/:id/dismiss-convert | WORKER (FOLLOW_UP dept) | Dismiss a suggested outreach convert { convertId } |
| DELETE | /follow-up/first-timers/:id/link-convert | WORKER (FOLLOW_UP dept) | Unlink the outreach convert |
| GET | /follow-up/tasks/mine | WORKER (FOLLOW_UP dept) | List follow-up tasks assigned to the caller |
| PATCH | /follow-up/tasks/:id | WORKER (FOLLOW_UP dept) | Update task status/outcome/notes+contactMethod (caller must be the assignee); sets lastActivityAt |
| POST | /follow-up/tasks/:id/notes | WORKER (FOLLOW_UP dept) | Add a note (with optional contactMethod) without changing task status; sets lastActivityAt |
| POST | /admin/follow-up/first-timers | AdminGuard (FOLLOW_UP_WRITE) | Register a first-timer from admin portal |
| GET | /admin/follow-up/first-timers | AdminGuard (FOLLOW_UP_READ) | List first-timers; query: page, limit, eventId, source, wantsToJoinChurch, wantsToJoinWorkforce, search, dateFrom, dateTo (YYYY-MM-DD) |
| GET | /admin/follow-up/first-timers/pipeline | AdminGuard (FOLLOW_UP_READ) | Funnel counts: { total, untouched, contacted, returned, invited, converted }. Optional from/to filter. |
| POST | /admin/follow-up/first-timers/:id/visits | AdminGuard (FOLLOW_UP_WRITE) | Log a return visit. Body: { eventId?, notes?, visitedAt? } — visitedAt defaults to today (YYYY-MM-DD) |
| GET | /admin/follow-up/tasks | AdminGuard (FOLLOW_UP_READ) | List follow-up tasks; query: page, limit, status, type, search (matches first-timer name) |
| GET | /admin/follow-up/tasks/stale | AdminGuard (FOLLOW_UP_READ) | Open tasks with no activity for ≥ daysInactive (default 7) days; paginated, ordered by oldest activity |
| PATCH | /admin/follow-up/tasks/:id/reassign | AdminGuard (FOLLOW_UP_WRITE) | Reassign a task to a different FOLLOW_UP-dept worker |
| PATCH | /admin/follow-up/tasks/bulk | AdminGuard (FOLLOW_UP_WRITE) | Bulk update task statuses |
| POST | /admin/follow-up/first-timers/:id/invite-to-membership | AdminGuard (FOLLOW_UP_WRITE) | Queue membership invitation email. Returns { queued: true/false }. Deduped by inviteSentAt. |
| PATCH | /admin/follow-up/first-timers/:id/mark-converted | AdminGuard (FOLLOW_UP_WRITE) | Mark first-timer as converted; optional { memberId } body links to their Member record (and marks a linked outreach convert joined) |
| POST | /admin/follow-up/first-timers/:id/link-convert | AdminGuard (FOLLOW_UP_WRITE) | Confirm a suggested outreach convert is this first-timer { convertId }; Follow-Up takes over |
| POST | /admin/follow-up/first-timers/:id/dismiss-convert | AdminGuard (FOLLOW_UP_WRITE) | “Not them” — stop suggesting { convertId } for this first-timer |
| DELETE | /admin/follow-up/first-timers/:id/link-convert | AdminGuard (FOLLOW_UP_WRITE) | Undo a wrong outreach match; convert returns to Evangelism unassigned |
| PATCH | /admin/follow-up/tasks/:id | AdminGuard (FOLLOW_UP_WRITE) | Admin update of any task: status, outcome, outcomeNotes, dueDate, noteContent, contactMethod |
| GET | /admin/follow-up/report | AdminGuard (FOLLOW_UP_READ) | Pastoral report: first-timer totals, task stats, overdue count, conversion rate, by-worker, by-event |
| POST | /evangelism/converts | WORKER | Add a convert (only name required); optional outreachId, allowDuplicate. 409 CONVERT_DUPLICATE on a known phone |
| POST | /evangelism/converts/:id/met-again | WORKER | Log a “Met again” follow-up on an existing convert (duplicate path) |
| GET | /evangelism/converts?scope=mine|team&…filters… | JwtAuthGuard (team needs Evangelism capability) |
My converts (added / on the outreach team / assigned) or the whole team list, with staleness + myRoles |
| POST | /evangelism/converts/:id/follow-up | Outreach team, assignee or Evangelism dept | Log a follow-up contact |
| PATCH | /evangelism/converts/:id/status | Outreach team, assignee or Evangelism dept | Update convert status |
| GET | /evangelism/converts/:id/follow-up-history?page=&limit= | Outreach team, assignee or Evangelism dept | Full follow-up log for a convert, newest first (mobile) |
| GET | /evangelism/workers?q= | WORKER | Active workers for team pickers (excludes caller) |
| POST | /evangelism/outreaches | WORKER | Start an outreach { title?, location?, date?, teamMemberIds }; creator always on the team |
| GET | /evangelism/outreaches/recent | WORKER | Outreaches the caller is on, last 14 days |
| PATCH | /evangelism/outreaches/:id/team | WORKER (on the team) | Replace the team (creator kept); pushes newly added members |
| GET | /evangelism/converts/admin?…filters…&stage= | AdminGuard (EVANGELISM_READ) | Cross-member browse (admin portal); stage=open|with_follow_up|joined |
| GET | /evangelism/converts/admin/workers?q= | AdminGuard (EVANGELISM_READ) | Active workers with Evangelism flag + open load, for assignee/team pickers |
| PATCH | /evangelism/converts/admin/bulk-reassign | AdminGuard (EVANGELISM_WRITE) | Move convertIds or all of fromWorkerProfileId’s open converts to toWorkerProfileId |
| PATCH | /evangelism/converts/admin/:id/reassign | AdminGuard (EVANGELISM_WRITE) | Assign follow-up to any active worker |
| PATCH | /evangelism/converts/admin/:id/unassign | AdminGuard (EVANGELISM_WRITE) | Clear the assignee |
| PATCH | /evangelism/converts/admin/:id/outreach | AdminGuard (EVANGELISM_WRITE) | Move a convert to another outreach, or null to detach |
| PATCH | /evangelism/converts/admin/:id/link-member | AdminGuard (EVANGELISM_WRITE) | Link a convert to their new Member record |
| GET | /evangelism/converts/admin/:id/follow-up-history?page=&limit= | AdminGuard (EVANGELISM_READ) | Full follow-up log for a convert, newest first (admin portal) |
| GET | /evangelism/outreaches/admin?from=&to= | AdminGuard (EVANGELISM_READ) | Outreaches with teams (up to 200) |
| PATCH | /evangelism/outreaches/admin/:id/team | AdminGuard (EVANGELISM_WRITE) | Replace an outreach team |
| GET/PATCH | /evangelism/settings/admin | AdminGuard (EVANGELISM_READ / _WRITE) | overdueDays (1–90), autoAssign |
| GET | /evangelism/report?from=&to= | AdminGuard (EVANGELISM_READ) | Summary, trend, per-worker and per-outreach report |
| GET | /evangelism/export?type=converts|workers|outreaches&… | AdminGuard (EVANGELISM_READ) + plan BULK_EXPORT | CSV export |
| POST | /admin/sermons | AdminGuard (SERMON_WRITE) | Create a sermon archive entry — body: title, speakerName, date, description?, youtubeUrl?, mixlrUrl?, series?. 400 if neither youtubeUrl nor mixlrUrl is set. |
| GET | /admin/sermons?page=&limit=&series= | AdminGuard (SERMON_READ) | Paginated list, newest first, optional exact series filter |
| GET | /admin/sermons/:id | AdminGuard (SERMON_READ) | Get a single sermon |
| PATCH | /admin/sermons/:id | AdminGuard (SERMON_WRITE) | Update any field. 400 if the update would leave both youtubeUrl and mixlrUrl unset. |
| DELETE | /admin/sermons/:id | AdminGuard (SERMON_WRITE) | Delete a sermon archive entry |
| POST | /admin/sermons/announce-live | AdminGuard (SERMON_WRITE) | Manual “we’re live” trigger — body: { platform: 'YOUTUBE' \| 'MIXLR', url, title? }. Publishes an ALL-audience system announcement via AnnouncementService.createSystemAnnouncement() and push-notifies every active member. |
| GET | /sermons?page=&limit=&series= | JwtAuthGuard + Module: sermons | Paginated list for any authenticated member/worker — no department or class gating |
| GET | /sermons/:id | JwtAuthGuard + Module: sermons | Get a single sermon |
| GET | /sermons/:id/note | JwtAuthGuard + Module: sermons | Get the requesting member’s own private note for this sermon (null if none) — own data, no admin visibility |
| PUT | /sermons/:id/note | JwtAuthGuard + Module: sermons | Create or update the requesting member’s note for this sermon (body: { note }, upsert) |
| DELETE | /sermons/:id/note | JwtAuthGuard + Module: sermons | Delete the requesting member’s note for this sermon |
| GET | /notes | JwtAuthGuard + Module: notes | The member’s own notes, paginated (page, limit ≤ 50, kind, q, sermonId); pinned first, then newest |
| GET | /notes/context | JwtAuthGuard + Module: notes | The service on now or earlier today, with speaker, same-day sermon and the member’s note for it (null if none) |
| GET | /notes/streak | JwtAuthGuard + Module: notes | Weekly sermon-notes streak: { current, best, thisWeek } |
| GET | /notes/top-scriptures | JwtAuthGuard + Module: notes | Most-noted refs for an event (eventId), only refs noted by 3+ members |
| POST | /notes/scripture-taps | JwtAuthGuard + Module: notes | Add batched taps on bible.com version links ({ taps: [{ version, count }] }), 204 |
| GET | /notes/preferences | JwtAuthGuard + Module: notes | { nudges } — whether the member gets Notes reminders |
| PUT | /notes/preferences | JwtAuthGuard + Module: notes | Turn the member’s Notes reminders on or off ({ nudges }) |
| GET | /notes/services | JwtAuthGuard + Module: notes | Services from the last 35 days the member can link a note to, with attended and their existing noteId |
| GET | /notes/:id | JwtAuthGuard + Module: notes | One of the member’s own notes, with its linked service (404 for anyone else’s) |
| POST | /notes | JwtAuthGuard + Module: notes | Create a note (kind?, title?, content, sermonId?, serviceSlotId?); returns the existing note for a service |
| PATCH | /notes/:id | JwtAuthGuard + Module: notes | Update title, content, pinned, sermonId, serviceSlotId (null unlinks; 409 NOTE_SERVICE_TAKEN); baseUpdatedAt guards edits made elsewhere |
| DELETE | /notes/:id | JwtAuthGuard + Module: notes | Delete one of the member’s own notes |
| GET | /admin/notes/insights | AdminGuard + SERMON_READ + Module: notes | Totals only: notes and members in the last 30 days, bible.com version taps over 90 days |
| GET | /integrations/youtube/callback | No guard — WebSub verification handshake | Echoes hub.challenge for subscribe/unsubscribe modes; 404 otherwise. Called by Google’s PubSubHubbub hub, not a client. |
| POST | /integrations/youtube/callback | No guard — WebSub notification | Receives the “video published” Atom feed ping; always 204. Triggers YouTube Data API check + auto-announcement if actually live. Called by the hub, not a client. |
| POST | /admin/games | AdminGuard (GAMES_WRITE) | Create a game (DRAFT) |
| GET | /admin/games?page=&limit=&search=&status= | AdminGuard (GAMES_READ) | Paginated list, newest first. search ILIKE-matches title/description; status filters on the raw GameStatusEnum column. Each game carries activeSessionCode (non-null only while LIVE_SESSION_ACTIVE) and playCount (count of its ENDED sessions) |
| GET | /admin/games/:id | AdminGuard (GAMES_READ) | Get a single game, with activeSessionCode and playCount |
| PATCH | /admin/games/:id | AdminGuard (GAMES_WRITE) | Update title/description/department/churchClass |
| DELETE | /admin/games/:id | AdminGuard (GAMES_WRITE) | Delete a game (cascades questions/sessions/participants/responses) |
| GET | /admin/games/:id/questions | AdminGuard (GAMES_READ) | List a game’s questions, ordered |
| POST | /admin/games/:id/questions | AdminGuard (GAMES_WRITE) | Add a question — body: questionText, options (>=2), correctOptionIndex, points?, timeLimitSeconds?. 400 if correctOptionIndex is out of range. |
| PUT | /admin/games/:id/questions/reorder | AdminGuard (GAMES_WRITE) | Reorder — body: { questionIds: string[] }, must contain exactly the game’s current question ids |
| PATCH | /admin/games/questions/:questionId | AdminGuard (GAMES_WRITE) | Update a question (any field) |
| DELETE | /admin/games/questions/:questionId | AdminGuard (GAMES_WRITE) | Delete a question |
| POST | /admin/games/:id/start | AdminGuard (GAMES_WRITE) | Start a session into its lobby (LIVE, no current question yet) — 400 if the game has no questions or a LIVE session already exists for it. Caller becomes the session’s host. |
| POST | /admin/games/sessions/:code/next-question | AdminGuard (GAMES_WRITE) | Advance to the next question (from the lobby, reveals Question 1) — 403 if caller isn’t the host, 400 if session isn’t LIVE or already on the last question |
| POST | /admin/games/sessions/:code/end | AdminGuard (GAMES_WRITE) | End the session (idempotent) and revert the game to DRAFT — any GAMES_WRITE admin, not host-restricted |
| GET | /admin/games/sessions/:code/state | AdminGuard (GAMES_READ) | Current session state (same shape broadcast over the socket, no correctOptionIndex, includes currentQuestionStartedAt) |
| GET | /admin/games/sessions/:code/leaderboard | AdminGuard (GAMES_READ) | Live leaderboard, ordered by totalScore desc, createdAt asc tie-break |
| GET | /admin/games/:id/sessions?page=&limit= | AdminGuard (GAMES_READ) | Past + current sessions for a game, with participantCount/topScore/topScorerName per session |
| POST | /games/sessions/:code/join | JwtAuthGuard + Module: games | Join a live session with its code — upserts a GameParticipant, no department/class gating |
| GET | /games/sessions/:code/state | Public + Module: games, throttled 300/60s | Current session state — same payload the socket broadcasts. Unauthenticated so the projector/screen presentation view can poll it with just the join code. |
| POST | /games/sessions/:code/questions/:questionId/answer | JwtAuthGuard + Module: games | Submit an answer — body: { selectedOptionIndex }. 400 if not the current question, already answered, or past the time limit + grace; 403 if caller never joined. |
| GET | /games/sessions/:code/leaderboard | JwtAuthGuard + Module: games | Live leaderboard |
| GET | /games/my-history?page=&limit= | JwtAuthGuard + Module: games | Caller’s own past (ENDED) sessions with score, rank, participantCount, correct/answered counts |
| POST | /service-ratings | JwtAuthGuard + Module: service_ratings | Submit or update a rating for a service (body: eventId, serviceSlotId, rating 1–5, comment?) — upsert |
| GET | /service-ratings/mine?eventId=&serviceSlotId= | JwtAuthGuard + Module: service_ratings | The requesting member’s own rating for a service, or null |
| GET | /admin/service-ratings/summary?eventId=&from=&to= | AdminGuard (SERVICE_RATING_READ) | Average rating, total count, and 1–5 star distribution |
| GET | /admin/service-ratings/comments?page=&limit= | AdminGuard (SERVICE_RATING_READ) | Paginated comment feed, anonymized unless the admin also has SERVICE_RATING_MODERATE |
| DELETE | /admin/service-ratings/:id | AdminGuard (SERVICE_RATING_MODERATE) | Delete/hide a rating; logs SERVICE_RATING_MODERATED |
| POST | /admin/volunteer-opportunities | AdminGuard (VOLUNTEER_WRITE) | Create an opportunity — body: title, description?, departmentId?, date, capacity? (omit for unlimited) |
| GET | /admin/volunteer-opportunities?page=&limit=&search=&status= | AdminGuard (VOLUNTEER_READ) | Paginated list, newest date first. search ILIKE-matches title/description; status filters on VolunteerOpportunityStatusEnum |
| PATCH | /admin/volunteer-opportunities/:id | AdminGuard (VOLUNTEER_WRITE) | Update any field |
| PATCH | /admin/volunteer-opportunities/:id/cancel | AdminGuard (VOLUNTEER_WRITE) | Cancel an opportunity (status → CANCELLED); no hard delete |
| GET | /admin/volunteer-opportunities/:id/signups | AdminGuard (VOLUNTEER_READ) | Roster — CONFIRMED signups with member names |
| GET | /volunteer-opportunities?page=&limit= | JwtAuthGuard + Module: volunteering | Open, upcoming opportunities; each row includes the caller’s own mySignupStatus |
| POST | /volunteer-opportunities/:id/signup | JwtAuthGuard + Module: volunteering | Sign up (upsert — re-signing after a cancel re-confirms the same row). 400 if not OPEN or at capacity. |
| DELETE | /volunteer-opportunities/:id/signup | JwtAuthGuard + Module: volunteering | Cancel the caller’s own signup |
| POST | /admin/small-groups | AdminGuard (SMALL_GROUP_WRITE) | Create a group — body: name, description?, leaderId?, meetingDay?, meetingLocation?, venueId?, meetingFormat?, meetingLink? |
| GET | /admin/small-groups?page=&limit=&search=&meetingFormat= | AdminGuard (SMALL_GROUP_READ) | Paginated list, alphabetical by name. search ILIKE-matches name/description; meetingFormat filters on MeetingFormatEnum |
| PATCH | /admin/small-groups/:id | AdminGuard (SMALL_GROUP_WRITE) | Update any field; leaderId: null/venueId: null explicitly unassigns the leader/venue |
| DELETE | /admin/small-groups/:id | AdminGuard (SMALL_GROUP_WRITE) | Hard delete — removes membership and attendance history too (CASCADE) |
| GET | /admin/small-groups/:id/members | AdminGuard (SMALL_GROUP_READ) | Full roster |
| DELETE | /admin/small-groups/:id/members/:memberId | AdminGuard (SMALL_GROUP_WRITE) | Force-remove a member; logs SMALL_GROUP_MEMBER_REMOVED |
| GET | /admin/small-groups/:id/attendance | AdminGuard (SMALL_GROUP_READ) | Full attendance history, newest meeting date first |
| GET | /small-groups?page=&limit= | JwtAuthGuard + Module: small_groups | Browse all groups with memberCount (not the roster itself) |
| GET | /small-groups/mine | JwtAuthGuard + Module: small_groups | Groups the caller currently belongs to |
| GET | /small-groups/:id | JwtAuthGuard + Module: small_groups | Group detail |
| GET | /small-groups/:id/members | JwtAuthGuard + Module: small_groups | Full roster — requires the caller to currently be a member of this group |
| POST | /small-groups/:id/join | JwtAuthGuard + Module: small_groups | Self-join (upsert — re-joining after leaving works) |
| DELETE | /small-groups/:id/leave | JwtAuthGuard + Module: small_groups | Self-leave |
| POST | /small-groups/:id/attendance | JwtAuthGuard + Module: small_groups | Record attendance — body: { meetingDate, records: [{memberId, status}] }. 403 unless the caller is this group’s leader. |
| POST | /events | AdminGuard (EVENTS_WRITE) | Create event (single or recurring). recurrence.ongoing: true makes an open-ended series (no recurrenceEndDate); autoProgramme: false skips draft programmes from templates |
| PATCH | /events/:id | AdminGuard (EVENTS_WRITE) | Update event |
| GET | /events/:id | Any | Get event by ID |
| GET | /events | Any | List events. Query: page, limit, orderBy, order, from (YYYY-MM-DD), to (YYYY-MM-DD), upcoming=true, search (case-insensitive match on event name — powers searchable event pickers in the admin frontend) |
| DELETE | /events/:id | AdminGuard (EVENTS_WRITE) | Delete single event — blocked if attendanceMarked = true or event is in the past |
| DELETE | /events/recurring/:recurringEventId | AdminGuard (EVENTS_WRITE) | Delete future recurring events (also deactivates the series) |
| GET | /events/series | AdminGuard (EVENTS_READ) | Active series with nextOccurrence and upcomingCount |
| GET | /events/series/:id | AdminGuard (EVENTS_READ) | One series (404 for pre-series recurring groups) |
| PATCH | /events/series/:id | AdminGuard (EVENTS_WRITE) | Edit from effectiveFrom — body: name?, description?, onlineAttendanceEnabled?, autoProgramme?, slotBlueprint?, effectiveFrom, confirmRecreate?. 409 SERIES_RECREATE_REQUIRED when services are added/removed |
| POST | /events/series/:id/stop | AdminGuard (EVENTS_WRITE) | Stop repeating — body { from }; removes upcoming dates without history, returns { removed } |
| GET | /events/templates | AdminGuard (EVENTS_READ) | Saved service types, by name (with audienceGroup) |
| GET | /events/audience-groups | AdminGuard (EVENTS_WRITE) | Groups an event can be for — [{ id, name }], by name |
| GET | /attendances/settings/online-window | AdminGuard (ATTENDANCE_READ) | Online-attendance confirmation window — { minutes, isDefault } |
| PATCH | /attendances/settings/online-window | AdminGuard (ATTENDANCE_WRITE) | Set the window — body { minutes } (15–10080); applies to the next service’s emails |
| POST | /events/templates | AdminGuard (EVENTS_WRITE) | Save a service type — body: name, description?, onlineAttendanceEnabled?, slotBlueprint, defaultRecurrence?, autoProgramme? |
| PATCH | /events/templates/:id | AdminGuard (EVENTS_WRITE) | Replace a service type (same body as POST) |
| DELETE | /events/templates/:id | AdminGuard (EVENTS_WRITE) | Delete a service type |
| POST | /event-config | AdminGuard (EVENTS_WRITE) | Create timing config — body gains defaultFormat? (IN_PERSON|ONLINE), onlineMeetingUrl?; defaultVenueId is now optional (required only when defaultFormat is IN_PERSON) |
| PATCH | /event-config/:id | AdminGuard (EVENTS_WRITE) | Update timing config — defaultVenueId: null explicitly clears the venue (needed when switching to ONLINE) |
| GET | /event-config/:id | AdminGuard (EVENTS_WRITE) | Get config by ID |
| GET | /event-config | AdminGuard (EVENTS_WRITE) | List configs |
| DELETE | /event-config/:id | AdminGuard (EVENTS_WRITE) | Delete config |
| POST | /events/slots/:slotId/reminders | AdminGuard (EVENTS_WRITE) | Add a reminder schedule to a slot |
| GET | /events/slots/:slotId/reminders | AdminGuard (EVENTS_WRITE) | List reminders for a slot |
| PATCH | /events/slots/:slotId/reminders/:reminderId | AdminGuard (EVENTS_WRITE) | Update reminder (audience, preset, enabled) |
| DELETE | /events/slots/:slotId/reminders/:reminderId | AdminGuard (EVENTS_WRITE) | Delete reminder |
| POST | /venues | AdminGuard (VENUES_WRITE) | Create venue |
| PATCH | /venues/:id | AdminGuard (VENUES_WRITE) | Update venue |
| DELETE | /venues/:id | AdminGuard (VENUES_WRITE) | Delete venue |
| GET | /venues | Any | List venues |
| GET | /venues/nearby | Any | Find nearby venues by radius |
| GET | /venues/:id | Any | Get venue by ID |
| GET | /departments | Any | List departments |
| GET | /departments/capabilities | Any | List all valid capabilities as { value, label }[] (the shared EnumOption shape used across /enums) — label is the human-readable description from DepartmentCapabilityLabels, for admin-UI display |
| GET | /departments/:id | Any | Get department |
| POST | /departments | AdminGuard (DEPARTMENTS_WRITE) | Create department |
| PATCH | /departments/:id | AdminGuard (DEPARTMENTS_WRITE) | Update department |
| DELETE | /departments/:id | AdminGuard (DEPARTMENTS_WRITE) | Delete department |
| POST | /departments/:id/bulk-assign | AdminGuard (DEPARTMENTS_WRITE) | Bulk assign workers to a primary department; returns { updated, skipped } |
| POST | /departments/assign-lead | AdminGuard (DEPARTMENTS_WRITE) | Assign head/assistant lead (accepts primary OR secondary department membership) |
| POST | /departments/remove-lead | AdminGuard (DEPARTMENTS_WRITE) | Remove lead |
| GET | /departments/leads/:id | AdminGuard (DEPARTMENTS_READ) | Leads for a department |
| GET | /departments/leads | AdminGuard (DEPARTMENTS_READ) | All department leads |
| GET | /departments/:id/workers | AdminGuard (DEPARTMENTS_READ) | List workers in a department (paginated) |
| GET | /departments/my/summary | WORKER | Own department summary (lead only) |
| POST | /pastor-feedback | JwtAuthGuard (HOD/D_HOD of departmentId) | Submit weekly pastor feedback |
| PATCH | /pastor-feedback/:id | JwtAuthGuard (must be the submitter) | Edit own submission |
| GET | /pastor-feedback/my?page=&limit= | JwtAuthGuard | Own submission history |
| GET | /pastor-feedback/admin?departmentId=&weekOf=&page=&limit= | AdminGuard (PASTOR_FEEDBACK_READ) | Cross-department browse |
| GET | /pastor-feedback/admin/department/:departmentId | AdminGuard (PASTOR_FEEDBACK_READ) | One department’s submission history |
| PATCH | /pastor-feedback/admin/:id | AdminGuard (PASTOR_FEEDBACK_WRITE) | Edit a submission on the HOD’s behalf |
| DELETE | /pastor-feedback/admin/:id | AdminGuard (PASTOR_FEEDBACK_WRITE) | Delete a submission |
| POST | /pastor-feedback/admin/:id/respond | AdminGuard (PASTOR_FEEDBACK_WRITE) | Respond as pastor (requires admin’s linked Member to have a Pastor record) |
| GET | /pastor-feedback/pastor?departmentId=&weekOf=&page=&limit= | JwtAuthGuard (Pastor record required) | Cross-department browse (mobile) |
| GET | /pastor-feedback/pastor/department/:departmentId | JwtAuthGuard (Pastor record required) | One department’s submission history (mobile) |
| POST | /pastor-feedback/pastor/:id/respond | JwtAuthGuard (Pastor record required) | Respond as pastor (mobile) |
| POST | /prayer-requests | Any (JwtAuthGuard) | Submit a private prayer request |
| GET | /prayer-requests/mine?page=&limit= | Any (JwtAuthGuard) | Own prayer request history |
| POST | /testimonies | Any (JwtAuthGuard) | Submit a testimony (optional prayerRequestId, isPublic); body: SubmitTestimonyDto |
| GET | /testimonies/mine?page=&limit= | Any (JwtAuthGuard) | Own testimony history |
| GET | /testimonies/public?page=&limit= | Any (JwtAuthGuard) | Opt-in public testimony feed |
| GET | /prayer-requests/team?status=&page=&limit= | JwtAuthGuard (Prayer-dept worker or Clergy) | Cross-member browse (mobile) |
| PATCH | /prayer-requests/team/:id/status | JwtAuthGuard (Prayer-dept worker or Clergy) | Update a request’s status (mobile) |
| GET | /prayer-requests/admin?status=&page=&limit= | AdminGuard (PRAYER_READ) | Cross-member browse (admin portal) |
| PATCH | /prayer-requests/admin/:id/status | AdminGuard (PRAYER_WRITE) | Update a request’s status (admin portal) |
| GET | /testimonies/admin?page=&limit= | AdminGuard (PRAYER_READ) | Full testimony browse (not just public ones) |
| GET | /prayer-requests/team/pregnancy-cases?status=&page=&limit= | JwtAuthGuard (Prayer-dept worker or Clergy) | Cross-member pregnancy prayer case browse (mobile) |
| POST | /prayer-requests/team/pregnancy-cases | JwtAuthGuard (Prayer-dept worker or Clergy) | Create a pregnancy prayer case (mobile) |
| POST | /prayer-requests/team/pregnancy-cases/:id/visit | JwtAuthGuard (Prayer-dept worker or Clergy) | Log a prayer visit (mobile) |
| PATCH | /prayer-requests/team/pregnancy-cases/:id/status | JwtAuthGuard (Prayer-dept worker or Clergy) | Update case status (mobile) |
| GET | /prayer-requests/team/pregnancy-cases/:id/visits | JwtAuthGuard (Prayer-dept worker or Clergy) | Full visit log for a case, newest first (mobile) |
| GET | /prayer-requests/admin/pregnancy-cases?status=&page=&limit= | AdminGuard (PRAYER_READ) | Cross-member pregnancy prayer case browse (admin portal) |
| PATCH | /prayer-requests/admin/pregnancy-cases/:id/status | AdminGuard (PRAYER_WRITE) | Update case status (admin portal) |
| GET | /prayer-requests/admin/pregnancy-cases/:id/visits | AdminGuard (PRAYER_READ) | Full visit log for a case, newest first (admin portal) |
| POST | /leave | WORKER | Request leave |
| PATCH | /leave/:id/action | AdminGuard (LEAVE_WRITE) | Approve or reject leave |
| DELETE | /leave/:id | WORKER | Delete own pending leave |
| GET | /leave/my-history?page=&limit=&status= | WORKER | Own leave history (paginated) |
| GET | /leave/history | AdminGuard (LEAVE_READ) | All leave requests |
| GET | /leave/department?page=&limit=&status= | WORKER | Department leave requests (lead only, paginated) |
| POST | /classes | AdminGuard (CLASSES_WRITE) | Create class (body: classTypeId, not type; optional minAttendancePercent, requireAllAssignments, openForRequests, capacity). Returns the full class (type, facilitators, materials) |
| PATCH | /classes/:id | AdminGuard (CLASSES_WRITE) | Update class; returns the full class (type, facilitators, materials) |
| DELETE | /classes/:id | AdminGuard (CLASSES_WRITE) | Delete class |
| GET | /classes?classTypeId= | Any | List classes (filterable by classTypeId) |
| GET | /classes/:id | Any | Get class |
| POST | /classes/enroll | AdminGuard (CLASSES_WRITE) | Enrol member in class |
| PATCH | /classes/enrollments/:id/status | AdminGuard (CLASSES_WRITE) | Update enrolment status |
| PATCH | /classes/enrollments/:id/certificate | AdminGuard (CLASSES_WRITE) | Issue a certificate for a COMPLETED enrolment (body: optional certificateNumber; next CERT-YYYY-NNNN when omitted) |
| GET | /classes/enrollments/:id/promotion-candidate | AdminGuard (CLASSES_READ) | Check level-promotion eligibility + open classes of the next type |
| POST | /classes/enrollments/:id/promote | AdminGuard (CLASSES_WRITE) | Promote a COMPLETED enrolment into the next class type (body: targetClassId) |
| GET | /classes/my/enrollments | Any | Own enrolments |
| GET | /classes/:id/enrollments | AdminGuard (CLASSES_READ) | All enrolments for a class |
| POST | /classes/types | AdminGuard (CLASSES_WRITE) | Create class type |
| PATCH | /classes/types/:id | AdminGuard (CLASSES_WRITE) | Update class type (name/description/isActive/nextClassTypeId) |
| DELETE | /classes/types/:id | AdminGuard (CLASSES_WRITE) | Delete class type (blocked if any class still references it) |
| GET | /classes/types | Any | List all class types (unpaginated, cached) — member-readable so the mobile app can show current types |
| GET | /classes/types/:id | Any | Get class type |
| POST | /announcements | AdminGuard (ANNOUNCEMENTS_WRITE) | Create announcement; optional sendViaSms (requires SMS_SEND) + smsBody (required if sendViaSms=true) |
| POST | /announcements/sms-broadcast | AdminGuard (SMS_SEND) | Send an SMS to an audience without creating an announcement; body { audience, departmentId?/targetMemberId?/groupId?, message } — returns { sentCount, failedCount?, failures? } |
| PATCH | /announcements/:id | AdminGuard (ANNOUNCEMENTS_WRITE) | Update announcement; same sendViaSms/smsBody rules as create — SMS only (re-)sent on the transition into sendViaSms=true |
| DELETE | /announcements/:id | AdminGuard (ANNOUNCEMENTS_WRITE) | Delete announcement |
| GET | /announcements/all?search=&audience=&page=&limit= | AdminGuard (ANNOUNCEMENTS_READ) | All announcements (paginated); optional search filters by title (case-insensitive); optional audience filters by value (ALL/WORKERS_ONLY/MEMBERS_ONLY/DEPARTMENT/INDIVIDUAL) |
| GET | /announcements/feed | Any | My filtered feed |
| GET | /announcements/:id | Any | Get announcement |
| POST | /announcements/:id/react | Any | React with an emoji (upserts — one reaction per member per announcement) |
| DELETE | /announcements/:id/react | Any | Remove own reaction |
| GET | /announcements/:id/reactions | Any | { summary: {emoji,count}[], myReaction } — myReaction reflects the calling member |
| GET | /admin/sms/balance | AdminGuard (SMS_READ) | Returns { balance, currency } from the SMS provider |
| POST | /admin/sms/segment-count | AdminGuard (SMS_READ) | Body { message } — returns { segments, encoding, characterCount } |
| GET | /admin/sms/logs | AdminGuard (SMS_READ) | Live passthrough to the provider’s message history — not paginated/filtered server-side |
| GET | /birthday/today | Any (JwtAuthGuard) | List active members with a birthday today (birthDay + birthMonth match current date) |
| GET | /birthday/upcoming | AdminGuard (MEMBERS_READ) | List active members with upcoming birthdays; ?days=N (default 7) sets the lookahead window, ordered by month/day |
| POST | /birthday/wishes/:recipientId | Any | Send a birthday wish (once per year per sender; rate-limited to WISH_DAILY_LIMIT/day) |
| GET | /birthday/wishes/me | Any | Read own birthday wishes (?year= filter optional) |
| GET | /birthday/wishes/:memberId | AdminGuard (MEMBERS_READ) | Read any member’s birthday wishes |
| GET | /dashboard/member | Any | Member dashboard |
| GET | /dashboard/worker | WORKER | Worker dashboard |
| GET | /dashboard/admin | AdminGuard (DASHBOARD_READ) | Admin dashboard |
| POST | /sunday-school/classes | WORKER (SS-dept or class teacher) | Create SS class |
| PATCH | /sunday-school/classes/:id | WORKER (SS-dept or class teacher) | Update SS class (incl. ageGroup, meetingDay, meetingTime, location, assistantIds) |
| DELETE | /sunday-school/classes/:id | AdminGuard (SUNDAY_SCHOOL_WRITE) | Delete SS class |
| GET | /sunday-school/classes | Any | List SS classes |
| GET | /sunday-school/classes/:id | Any | Get SS class by ID |
| POST | /sunday-school/classes/:id/members | WORKER (SS-dept or class teacher) | Assign member to class. This, bulk add and candidates return 403 when teachersCanAddMembers is off |
| POST | /sunday-school/classes/:id/members/bulk | WORKER (SS-dept or class teacher) | Bulk add members by id and/or email — same body, rules and response as the admin bulk add. Used by the member app’s “Add members” picker |
| GET | /sunday-school/classes/:id/candidates?search=&page=&limit= | WORKER (SS-dept or class teacher) | Members who can be added to the class — same response as the admin candidates endpoint (otherClasses, blocked) |
| DELETE | /sunday-school/classes/:id/members/:memberId | WORKER (SS-dept or class teacher) | Remove member from class |
| GET | /sunday-school/classes/:id/members | WORKER (SS-dept or class teacher) | List class members |
| POST | /sunday-school/sessions | WORKER (SS-dept or class teacher) | Create SS session |
| POST | /sunday-school/sessions/series | WORKER (SS-dept or class teacher) | Create a session every N weeks between two dates (skips existing dates; ≤60) |
| PATCH | /sunday-school/sessions/:id | WORKER (SS-dept or class teacher) | Edit a session’s date, notes or lesson link; attendance is kept |
| GET | /sunday-school/classes/:id/absentees?misses= | WORKER (SS-dept, class teacher or assistant) | Members who missed their last N sessions in a row (default 3) |
| PATCH | /sunday-school/sessions/:id/open | WORKER (SS-dept or class teacher) | Open self-mark window for N minutes (body: { closesInMinutes }); pushes “check-in open” to unmarked class members |
| PATCH | /sunday-school/sessions/:id/close | WORKER (SS-dept or class teacher) | Close self-mark window immediately |
| GET | /sunday-school/sessions/open | Any authenticated member | List sessions with an active self-mark window that the member is enrolled in |
| GET | /sunday-school/attendance/me | Any authenticated member | Paginated list of the member’s own Sunday School attendance history |
| POST | /sunday-school/sessions/:id/checkin | Any (self-mark; member must be enrolled; window must be open) | Self-mark attendance |
| POST | /sunday-school/sessions/:id/bulk-mark | WORKER (SS-dept or class teacher) | Bulk mark session attendance; 403 after the teacher marking window (teacherMarkingDays) |
| POST | /sunday-school/sessions/:id/checkin-first-timer | WORKER (SS-dept or class teacher) | Check in someone with no Member record — creates a real FirstTimer (+ follow-up task) and marks them PRESENT. 403 when teachersCanCheckInFirstTimers is off |
| GET | /sunday-school/settings | WORKER | { teachersCanAddMembers, teachersCanCheckInFirstTimers, oneClassPerMember } — the member app hides switched-off teacher options |
| GET | /sunday-school/sessions/:id/roster | WORKER (SS-dept or class teacher) | Get session attendance roster — also returns firstTimerCheckIns[], teacherMarkingOpen, teacherMarkingClosesOn |
| GET | /sunday-school/sessions?classId= | Any | List sessions for a class (paginated) |
| GET | /sunday-school/sessions/:id | Any | Get SS session by ID |
| DELETE | /sunday-school/sessions/:id | AdminGuard (SUNDAY_SCHOOL_WRITE) | Delete SS session |
| GET | /sunday-school/my-classes | Any authenticated member | Classes the caller is assigned to (with teacher and class details) |
| GET | /sunday-school/my-teaching | WORKER | Classes the caller teaches or assists, with membersCount |
| POST | /sunday-school/classes/:id/questions | Any (member must be enrolled in the class) | Ask a private question in a class |
| GET | /sunday-school/classes/:id/questions | WORKER (SS-dept or class teacher) | List questions asked in a class (paginated) |
| GET | /sunday-school/questions/me | Any authenticated member | Paginated list of the member’s own questions, across all classes |
| PATCH | /sunday-school/questions/:id/answer | WORKER (SS-dept or class teacher) | Answer a question (body: { answerText }) |
| GET | /sunday-school/questions | WORKER (SS-dept capability only, no class-teacher fallback) | Questions across every class, paginated |
| GET | /admin/sunday-school/classes | AdminGuard (SUNDAY_SCHOOL_READ) | List SS classes (paginated) |
| POST | /admin/sunday-school/classes | AdminGuard (SUNDAY_SCHOOL_WRITE) | Create SS class (no auth restriction on department or teacher) |
| PATCH | /admin/sunday-school/classes/:id | AdminGuard (SUNDAY_SCHOOL_WRITE) | Update SS class (incl. ageGroup, meetingDay, meetingTime, location, assistantIds) |
| DELETE | /admin/sunday-school/classes/:id | AdminGuard (SUNDAY_SCHOOL_WRITE) | Delete SS class |
| GET | /admin/sunday-school/classes/:id/members | AdminGuard (SUNDAY_SCHOOL_READ) | List members of an SS class (paginated) |
| POST | /admin/sunday-school/classes/:id/members | AdminGuard (SUNDAY_SCHOOL_WRITE) | Assign a member to an SS class |
| POST | /admin/sunday-school/classes/:id/members/bulk | AdminGuard (SUNDAY_SCHOOL_WRITE) | Add many members at once. Body { memberIds?: uuid[], emails?: string[] } (≤500 each, at least one). Members already in the class are skipped; with one-class-per-member on, members already in another class are skipped too. Returns { added, alreadyInClass, notFound, inAnotherClass: [{ memberId, className }] } (notFound = unknown ids/emails). The admin’s class panel uses it for “Choose from list” and “Paste emails” |
| GET | /admin/sunday-school/classes/:id/candidates?search=&page=&limit= | AdminGuard (SUNDAY_SCHOOL_READ) | Members who can be added to the class (not already in it, not INACTIVE); search matches first/last/full name or email; limit ≤100. Each row has otherClasses: string[] and blocked (true when one-class-per-member is on and they’re in another class). Response also carries oneClassPerMember |
| GET | /admin/sunday-school/settings | AdminGuard (SUNDAY_SCHOOL_READ) | { oneClassPerMember, teachersCanAddMembers, teachersCanCheckInFirstTimers, teacherMarkingDays, membersInSeveralClasses }. teacherMarkingDays is the church_settings row sunday_school:teacher_marking_days { days } (default 2, 0–30). Each switch is a church_settings row { enabled }: sunday_school:one_class_per_member (default false), sunday_school:teachers_can_add_members (default true), sunday_school:teachers_can_check_in_first_timers (default true); each cached 5 min |
| PUT | /admin/sunday-school/settings | AdminGuard (SUNDAY_SCHOOL_WRITE) | Body: any of { oneClassPerMember?, teachersCanAddMembers?, teachersCanCheckInFirstTimers?, teacherMarkingDays? } (booleans, plus teacherMarkingDays 0–30; only sent fields change). teachersCanAddMembers: false → teacher add/bulk add/candidates return 403 (admins only); teachersCanCheckInFirstTimers: false → teacher first-timer check-in returns 403 (admins use the admin route). oneClassPerMember on: adding a member who is already in another class (admin single/bulk add or worker assign) is refused with 400; existing extra memberships are left alone and counted in membersInSeveralClasses. Audited as SUNDAY_SCHOOL_SETTINGS_UPDATED |
| DELETE | /admin/sunday-school/classes/:id/members/:memberId | AdminGuard (SUNDAY_SCHOOL_WRITE) | Remove a member from an SS class |
| GET | /admin/sunday-school/sessions?classId= | AdminGuard (SUNDAY_SCHOOL_READ) | List sessions for a class (paginated; classId required UUID query param) |
| POST | /admin/sunday-school/sessions | AdminGuard (SUNDAY_SCHOOL_WRITE) | Create SS session |
| POST | /admin/sunday-school/sessions/series | AdminGuard (SUNDAY_SCHOOL_WRITE) | Create a session every N weeks between two dates (skips existing dates; ≤60) |
| PATCH | /admin/sunday-school/sessions/:id | AdminGuard (SUNDAY_SCHOOL_WRITE) | Edit a session’s date, notes or lesson link; attendance is kept |
| GET | /admin/sunday-school/reports/attendance?from&to&classId | AdminGuard (SUNDAY_SCHOOL_READ) | Attendance report: summary, per class, per session, per member (with classId) — see Sunday School Module |
| GET | /admin/sunday-school/reports/attendance/export?from&to&classId | AdminGuard (SUNDAY_SCHOOL_READ) | .xlsx download: Classes, Sessions, Members sheets |
| GET | /admin/sunday-school/reports/absentees?classId&misses | AdminGuard (SUNDAY_SCHOOL_READ) | Members who missed their last N sessions in a row, all classes or one |
| DELETE | /admin/sunday-school/sessions/:id | AdminGuard (SUNDAY_SCHOOL_WRITE) | Delete SS session |
| PATCH | /admin/sunday-school/sessions/:id/open | AdminGuard (SUNDAY_SCHOOL_WRITE) | Open self-mark window (body: { closesInMinutes }); pushes “check-in open” to unmarked class members |
| PATCH | /admin/sunday-school/sessions/:id/close | AdminGuard (SUNDAY_SCHOOL_WRITE) | Close self-mark window |
| GET | /admin/sunday-school/sessions/:id/roster | AdminGuard (SUNDAY_SCHOOL_READ) | Get session attendance roster |
| POST | /admin/sunday-school/sessions/:id/bulk-mark | AdminGuard (SUNDAY_SCHOOL_WRITE) | Bulk mark session attendance; returns { marked: number } |
| POST | /admin/sunday-school/sessions/:id/checkin-first-timer | AdminGuard (SUNDAY_SCHOOL_WRITE) | Admin first-timer check-in — same body/behaviour as the teacher route, FirstTimer recorded with the admin as creator; unaffected by teachersCanCheckInFirstTimers. Used by the admin “Mark attendance” window |
| GET | /admin/sunday-school/questions | AdminGuard (SUNDAY_SCHOOL_READ) | Questions across every class, paginated |
| GET | /admin/sunday-school/classes/:id/questions | AdminGuard (SUNDAY_SCHOOL_READ) | List questions asked in a class (paginated) |
| PATCH | /admin/sunday-school/questions/:id/answer | AdminGuard (SUNDAY_SCHOOL_WRITE) | Answer a question (body: { answerText }) |
| DELETE | /admin/sunday-school/questions/:id | AdminGuard (SUNDAY_SCHOOL_WRITE) | Delete a question (moderation) |
| POST | /children-church/age-groups | AdminGuard (CHILDREN_CHURCH_WRITE) | Create age group |
| PATCH | /children-church/age-groups/:id | AdminGuard (CHILDREN_CHURCH_WRITE) | Update age group |
| DELETE | /children-church/age-groups/:id | AdminGuard (CHILDREN_CHURCH_WRITE) | Delete age group |
| GET | /children-church/age-groups | Any | List age groups |
| POST | /children-church/age-groups/recompute | AdminGuard (CHILDREN_CHURCH_WRITE) | Batch reassign all children to correct age/class group |
| POST | /children-church/class-groups | AdminGuard (CHILDREN_CHURCH_WRITE) | Create class group |
| PATCH | /children-church/class-groups/:id | AdminGuard (CHILDREN_CHURCH_WRITE) | Update class group |
| DELETE | /children-church/class-groups/:id | AdminGuard (CHILDREN_CHURCH_WRITE) | Delete class group |
| GET | /children-church/class-groups?ageGroupId= | WORKER (CC-dept) | List class groups (filterable by age group) |
| POST | /children-church/children | WORKER (CC-dept) | Register child |
| PATCH | /children-church/children/:id | WORKER (CC-dept) | Update child profile |
| GET | /children-church/children/:id | WORKER (CC-dept) | Get child by ID |
| GET | /children-church/children/:id/checkin-history | WORKER (CC-dept) | Child check-in history (paginated) |
| GET | /children-church/children?name=&classGroupId=&page=&limit= | WORKER (CC-dept) | Search/list children |
| POST | /children-church/children/:id/guardians | WORKER (CC-dept) | Add guardian to child |
| GET | /children-church/children/:id/guardians | WORKER (CC-dept) | List child guardians |
| DELETE | /children-church/guardians/:id | WORKER (CC-dept) | Remove guardian |
| POST | /children-church/checkin | WORKER (CC-dept) | Check in a child |
| GET | /children-church/checkin/verify/:code | WORKER (CC-dept) | Verify pickup code |
| POST | /children-church/checkout | WORKER (CC-dept) | Check out a child |
| PATCH | /children-church/checkin/:id/flag | WORKER (CC-dept) | Flag a check-in record |
| GET | /children-church/checkin/active?classGroupId= | WORKER (CC-dept) | List active check-ins |
| GET | /children-church/admin/checkin/active?classGroupId= | AdminGuard (CHILDREN_CHURCH_READ) | Admin view — all active check-ins, optional class group filter |
| GET | /children-church/admin/checkin/history?page=&limit=&classGroupId=&status=&slotId= | AdminGuard (CHILDREN_CHURCH_READ) | Admin paginated check-in history; filters: classGroupId, status (CHECKED_IN/CHECKED_OUT/FLAGGED), slotId |
| GET | /children-church/checkin/slot/:slotId?page=&limit= | AdminGuard (CHILDREN_CHURCH_READ) | All check-ins for a service slot (paginated, default limit 20) |
| GET | /admin/tithes/records | AdminGuard (FINANCE_READ) | List all confirmed tithe records (paginated); filters: memberId, departmentId, fromMonth, toMonth, search |
| GET | /admin/tithes/records/download | AdminGuard (FINANCE_READ) | Download filtered tithe records as .xlsx; same query params as list endpoint, no pagination |
| GET | /admin/tithes/template | AdminGuard (FINANCE_READ) | Download the tithe upload Excel template (3-sheet workbook) |
| POST | /admin/tithes/upload | AdminGuard (FINANCE_WRITE) | Upload tithe payment Excel; validates headers, creates batch, dispatches Bull job |
| GET | /admin/tithes/batches?status=&page=&limit= | AdminGuard (FINANCE_READ) | List all upload batches (paginated); optional status filter (PENDING/PROCESSING/COMPLETED/FAILED) |
| GET | /admin/tithes/batches/:id | AdminGuard (FINANCE_READ) | Get batch by ID |
| POST | /admin/tithes/batches/:id/requeue | AdminGuard (FINANCE_WRITE) | Requeue a FAILED batch using stored row data; resets status to PENDING |
| GET | /admin/tithes/unmatched?status=&search=&page=&limit= | AdminGuard (FINANCE_READ) | List unmatched rows; status defaults to PENDING; search filters by rawEmail or reference (case-insensitive) |
| POST | /admin/tithes/unmatched/:id/match | AdminGuard (FINANCE_WRITE) | Manually match an unmatched row to a member; creates TitheRecord |
| POST | /admin/tithes/unmatched/:id/dismiss | AdminGuard (FINANCE_WRITE) | Mark an unmatched row as DISMISSED (intentionally ignored) |
| GET | /admin/tithes/disputes?status=&search=&page=&limit= | AdminGuard (FINANCE_READ) | List dispute records; status defaults to PENDING; search filters by member firstname, lastname, or email |
| PATCH | /admin/tithes/disputes/:id/approve | AdminGuard (FINANCE_WRITE) | Approve a tithe dispute (creates TitheRecord) |
| PATCH | /admin/tithes/disputes/:id/reject | AdminGuard (FINANCE_WRITE) | Reject a tithe dispute |
| GET | /tithes/me | Any (JwtAuthGuard) | Member’s own tithe records (paginated) |
| GET | /tithes/me/summary | Any (JwtAuthGuard) | The caller’s giving for one year by type (year query, default current year): { year, years, total, count, byType } |
| POST | /tithes/me/statement/send | Any (JwtAuthGuard) | Email a PDF Giving Statement to the caller’s registered email. Optional query: fromMonth (YYYY-MM), toMonth (YYYY-MM), givingOptionId (UUID). Selecting an option includes matching TitheRecords only; omitting it includes all giving, including confirmed pledge contributions |
| POST | /tithes/me/pledge-statement/send | Any (JwtAuthGuard) | Email a PDF statement of the caller’s confirmed pledge contributions only. Optional query fromMonth, toMonth (YYYY-MM), campaignId. Returns a message and recordCount; sends no email when nothing matches |
| POST | /tithes/proof | Any (JwtAuthGuard) | Submit tithe payment proof (multipart, field: file, max 2 MB); body: titheAccountId, amount, paymentDate, reference?, givingOptionId? (what this payment was for; omit for General Giving) |
| GET | /tithes/proof | Any (JwtAuthGuard) | List caller’s own tithe payment proofs (paginated) |
| GET | /admin/tithes/proofs?status=&search=&page=&limit= | AdminGuard (FINANCE_READ) | List all tithe payment proofs; optional status filter (PENDING/CONFIRMED/DECLINED); search filters by member firstname, lastname, or email |
| POST | /admin/tithes/proofs/:id/confirm | AdminGuard (FINANCE_WRITE) | Confirm a tithe payment proof; creates a TitheRecord (source MANUAL_PROOF) so it appears in the member’s giving history/statement, and notifies member by email |
| POST | /admin/tithes/proofs/:id/decline | AdminGuard (FINANCE_WRITE) | Decline a tithe payment proof (body: financeNote); notifies member by email |
| GET | /admin/finance/categories | AdminGuard (FINANCE_READ) | List finance categories |
| POST | /admin/finance/categories | AdminGuard (FINANCE_WRITE) | Create finance category |
| PATCH | /admin/finance/categories/:id | AdminGuard (FINANCE_WRITE) | Update finance category (name, description, or isActive) |
| DELETE | /admin/finance/categories/:id | AdminGuard (FINANCE_WRITE) | Delete a finance category; 400 if the category is referenced by any FinanceRequest (disable it via PATCH isActive: false instead) |
| GET | /admin/finance/requests | AdminGuard (FINANCE_READ) | List finance requests (paginated); filters: status (incl. AWAITING_PAYMENT, PAID), categoryId, memberId, departmentId, search; rows include computed isPaid |
| GET | /admin/finance/requests/download | AdminGuard (FINANCE_READ) | Download filtered finance requests as .xlsx; same query params as list endpoint, no pagination |
| GET | /admin/finance/requests/:id | AdminGuard (FINANCE_READ) | Get finance request by ID |
| PATCH | /admin/finance/requests/:id/approve | AdminGuard (FINANCE_WRITE) | Approve a pending finance request — 403 if the approver is the same member who raised the request |
| PATCH | /admin/finance/requests/:id/reject | AdminGuard (FINANCE_WRITE) | Reject a pending finance request (body: rejectionReason) |
| PATCH | /admin/finance/requests/:id/proof | AdminGuard (FINANCE_WRITE) | Attach payment proof to an approved request (multipart, field: file) |
| GET | /finance/categories | WORKER (RolesGuard) | List finance categories (visible to HOD for request creation); only isActive: true categories are returned |
| POST | /finance/requests | WORKER — HOD only | Raise a finance request for own department (multipart optional: attachment) |
| GET | /finance/requests | WORKER — HOD only | List own department’s finance requests (paginated); each row includes department and requestedBy relations |
| GET | /finance/requests/:id | WORKER — HOD only | Get a single request from own department (includes proofUrl once attached) |
| POST | /service-programme | AdminGuard + SERVICE_PROGRAMME_WRITE | Create a programme for one or more service slots in one call — body is { programmes: [{ serviceSlotId, slots? }], saveAsTemplate? } (programmes min 1). One ServiceProgramme per slot still (1:1 with ServiceSlot), but a multi-service Sunday (First/Second Service under one Event) can be programmed in a single request instead of one round trip per slot. Each entry’s slots (order-of-service items) is independent — sibling slots are not required to have matching items, or any items at all. 404 if any serviceSlotId doesn’t exist; 409 (naming the affected slots) if any already has a programme — the whole call is rejected, none are created. Each slots item is created the same way POST /service-programme/:id/slots would (member/backup resolution, assignment email, conflict-warning check), in array order starting at position 0. saveAsTemplate applies to every programme created in the call. Response is a single fully-loaded programme (same shape as GET /service-programme/:id) when programmes has one entry, or an array of them when it has multiple. Omitting an entry’s slots still creates that programme as an empty DRAFT, added to later. |
| GET | /service-programme | AdminGuard + SERVICE_PROGRAMME_READ | List all programmes paginated (query: page, limit). Each result includes structured event: { id, name, eventDate } and serviceSlotDetail: { id, name, startTime, endTime } (in addition to the flattened serviceSlotName string) so the admin UI can group programmes by their parent event instead of rendering every slot as an unrelated row. |
| GET | /service-programme/my-assignments | JwtAuthGuard | The calling member’s own upcoming slots (as primary or backup) across every DRAFT/LIVE programme, ordered by service start time. Each entry includes isBackup, so a member on standby can tell it apart from a confirmed slot. Excludes COMPLETED programmes and anything already in the past. Also includes sessionCode — null until the programme’s session goes LIVE, then the code needed to call GET /service-session/:sessionCode/my-status. Includes slots held by the caller’s department(s), with asDepartment: { id, name }. |
| GET | /service-programme/upcoming | JwtAuthGuard | The general order-of-service view — the soonest LIVE-or-still-upcoming-DRAFT programme, with every slot (not scoped to the caller), mapped to speakerName/backupSpeakerName strings only (never the raw Member row). Returns null rather than 404ing when nothing qualifies. Also includes sessionCode once LIVE. Must stay registered before :id below in the controller. |
| GET | /service-programme/templates | AdminGuard + SERVICE_PROGRAMME_READ | List all reusable programme templates ordered by name |
| DELETE | /service-programme/templates/:templateId | AdminGuard + SERVICE_PROGRAMME_WRITE | Delete a template |
| GET | /service-programme/:id | AdminGuard + SERVICE_PROGRAMME_READ | Get a single programme with all slots and member relations. Each slot includes flattened memberName/backupMemberName strings derived from the loaded member/backupMember relations, so the frontend never has to resolve the relation object itself. |
| PATCH | /service-programme/:id | AdminGuard + SERVICE_PROGRAMME_WRITE | Update programme metadata (saveAsTemplate flag). Existed with no frontend consumer until now — ProgrammeDetailPanel has a “Save as template when completed” toggle next to the status flow. |
| DELETE | /service-programme/:id | AdminGuard + SERVICE_PROGRAMME_WRITE | Delete a DRAFT programme — 400 if LIVE or COMPLETED |
| POST | /service-programme/:id/slots | AdminGuard + SERVICE_PROGRAMME_WRITE | Append a slot (appended at next position). If memberId is set and that member has an email, queues a service-slot-assigned notification email. Accepts departmentId/backupDepartmentId instead of a member/guest (400 if both); the department’s members get a push and the HOD the email. |
| PUT | /service-programme/:id/slots/reorder | AdminGuard + SERVICE_PROGRAMME_WRITE | Reorder all slots (body: { slots: [{ id }] } in desired order) — DRAFT programmes only; for LIVE sessions use PUT /service-session/:sessionCode/slots/reorder |
| PATCH | /service-programme/:id/slots/:slotId | AdminGuard + SERVICE_PROGRAMME_WRITE | Update a single slot — 400 if programme is not DRAFT. Queues a service-slot-assigned email only when memberId newly changes to a different member (no email on unrelated edits or on clearing the assignment). departmentId/backupDepartmentId switch the slot to/from a department (the person is cleared). |
| DELETE | /service-programme/:id/slots/:slotId | AdminGuard + SERVICE_PROGRAMME_WRITE | Remove a slot — 400 if programme is not DRAFT |
| POST | /service-programme/:id/apply-template/:templateId | AdminGuard + SERVICE_PROGRAMME_WRITE | Apply a template to a DRAFT programme (clears existing slots, copies template structure) |
| GET | /service-programme/event/:eventId/pdf | AdminGuard + SERVICE_PROGRAMME_READ | Download the full event programme as a PDF (application/pdf). Covers every service slot in the event ordered by start time. Each service shows its programme slots (type, topic, speaker, backup, minutes) or a “no programme” notice if not yet created. Filename derived from event name. |
| GET | /service-programme/:id/pdf | AdminGuard + SERVICE_PROGRAMME_READ | Download a single programme as a PDF (application/pdf). Includes slot name, event date/time, all slots with type, topic, speaker, backup, and allocated minutes. |
| GET | /service-programme/:id/sessions | AdminGuard + SERVICE_PROGRAMME_READ | Paginated list of historical sessions for a programme (query: page, limit). Existed with no frontend consumer until now — ProgrammeDetailPanel has a collapsible “Session History” section (usually 0–1 entries under current business rules, since a programme can’t be restarted once it leaves DRAFT; the endpoint exists for the audit trail regardless). |
| POST | /service-session/programme/:programmeId/start | JwtAuthGuard (+ assertCanControlSession) | Start a session for a DRAFT programme; returns session with sessionCode and generates a Redis shareToken |
| POST | /service-session/event/:eventId/start | JwtAuthGuard (+ assertCanControlSession) | Starts only the next DRAFT programme in the event (earliest serviceSlot.startTime); returns a single session. 409 if a session for this event is already LIVE — end it first. 404 if the event has no service slots; 400 if no programme is startable (all already started/completed, or none have slots yet). Call again after ending the current session to advance to the next slot. |
| POST | /service-session/:sessionCode/advance | JwtAuthGuard (+ assertCanControlSession) | Advance to next slot; returns updated Redis anchor |
| POST | /service-session/:sessionCode/rewind | JwtAuthGuard (+ assertCanControlSession) | Go back to previous slot — 400 if already at first slot |
| POST | /service-session/:sessionCode/pause | JwtAuthGuard (+ assertCanControlSession) | Pause session (body: reason); creates ServicePauseEntry |
| POST | /service-session/:sessionCode/resume | JwtAuthGuard (+ assertCanControlSession) | Resume paused session; adjusts slotBaseSeconds to exclude pause duration |
| POST | /service-session/:sessionCode/adjust-time | JwtAuthGuard (+ assertCanControlSession) | Add/subtract seconds from the running slot’s remaining time (body: { deltaSeconds }, -3600…3600) |
| PUT | /service-session/:sessionCode/slots/reorder | JwtAuthGuard (+ assertCanControlSession) | Reorder the not-yet-started (PENDING) tail of ServiceSessionSlot rows for a LIVE session (body: { slots: [{ id }] }) — distinct from the DRAFT-only /service-programme/:id/slots/reorder |
| POST | /service-session/:sessionCode/slots/:position/override | RolesGuard (WORKER) + Admin dept | Runtime override for a slot (speakerName, topic, allocatedMinutes, memberId) |
| POST | /service-session/:sessionCode/end | JwtAuthGuard (+ assertCanControlSession) | End session; marks remaining slots SKIPPED; auto-saves template if saveAsTemplate |
| GET | /service-session/:sessionCode/share-links | JwtAuthGuard (+ assertCanControlSession) | Returns { sessionCode, shareToken } for building the public Presentation/Programme Manager links. Self-healing: if the session’s anchor is live but no shareToken was ever written (a race with the fire-and-forget set() in start(), a transient Redis hiccup, or a session that’s been live since before this field existed), a new token is generated and persisted on the fly instead of 404ing forever |
| POST | /service-session/:sessionCode/rotate-share-token | JwtAuthGuard (+ assertCanControlSession) | Regenerates the Redis-stored shareToken, invalidating any previously shared Programme Manager link without ending the session |
| POST | /service-session/:sessionCode/access-grants | JwtAuthGuard (+ assertCanControlSession) | Generate a named, individually-revocable PM-link credential (body: { name }); returns { id, name, pin } — the plaintext 6-digit PIN is shown exactly once and never retrievable again |
| GET | /service-session/:sessionCode/access-grants | JwtAuthGuard (+ assertCanControlSession) | List access grants for the session ({ id, name, createdAt, revokedAt, lastUsedAt }[], no PIN/hash exposed) |
| POST | /service-session/:sessionCode/access-grants/:grantId/revoke | JwtAuthGuard (+ assertCanControlSession) | Revoke a named grant; takes effect on that person’s next pm/* action without touching anyone else’s access or the shared link |
| POST | /service-session/:sessionCode/pm/access | Public + ShareTokenGuard (?token=) | Sign in to the Programme Manager link with { name, pin }; returns { grantToken, name } on success — this is the identity step itself, so it’s the one pm/* route that isn’t also gated by NamedAccessGuard |
| POST | /service-session/:sessionCode/pm/advance | Public + ShareTokenGuard (?token=) + NamedAccessGuard (?grantToken=) | Same as /advance, callable from the public Programme Manager link |
| POST | /service-session/:sessionCode/pm/rewind | Public + ShareTokenGuard (?token=) + NamedAccessGuard (?grantToken=) | Same as /rewind, callable from the public Programme Manager link |
| POST | /service-session/:sessionCode/pm/pause | Public + ShareTokenGuard (?token=) + NamedAccessGuard (?grantToken=) | Same as /pause, callable from the public Programme Manager link |
| POST | /service-session/:sessionCode/pm/resume | Public + ShareTokenGuard (?token=) + NamedAccessGuard (?grantToken=) | Same as /resume, callable from the public Programme Manager link |
| POST | /service-session/:sessionCode/pm/adjust-time | Public + ShareTokenGuard (?token=) + NamedAccessGuard (?grantToken=) | Same as /adjust-time, callable from the public Programme Manager link |
| PUT | /service-session/:sessionCode/pm/slots/reorder | Public + ShareTokenGuard (?token=) + NamedAccessGuard (?grantToken=) | Same as /slots/reorder, callable from the public Programme Manager link |
| POST | /service-session/:sessionCode/pm/slots/:position/override | Public + ShareTokenGuard (?token=) + NamedAccessGuard (?grantToken=) | Same as /slots/:position/override, callable from the public Programme Manager link — lets the PM rename a topic or swap the minister/speaker mid-service, not just admins |
| POST | /service-session/:sessionCode/pm/end | Public + ShareTokenGuard (?token=) + NamedAccessGuard (?grantToken=) | Same as /end, callable from the public Programme Manager link (session end is included in the public link’s scope by product decision) |
| GET | /service-session/active | AdminGuard + SERVICE_PROGRAMME_READ | Returns { sessionCode, serviceSlotName, startedAt }[] for every currently LIVE session — powers the global “Live” indicator shown in the admin top bar on every page |
| GET | /service-session/analytics | AdminGuard + SERVICE_PROGRAMME_READ | Aggregate analytics across COMPLETED sessions (query: from, to, serviceSlotName — matches either the sub-service’s own name or its parent event’s name; memberId — restricts to sessions this member appeared in, as originally assigned or as whoever stepped in; slotType — restricts the type/speaker breakdown to one ServiceSlotTypeEnum value); overrun stats, avg times, top speakers |
| GET | /service-session/my-history?page=&limit= | JwtAuthGuard | The calling member’s own COMPLETED-session slot history (query: page, limit; default 1/10). Returns { totalSlots, totalActualSeconds, bySlotType: [{type, count, totalActualSeconds}], entries: [{eventName, serviceSlotName, sessionDate, type, topic, allocatedMinutes, actualSeconds}], page, limit, totalCount, totalPages }. Summary/bySlotType are computed over the caller’s full history, not just the current page. Credits only the effective speaker of a slot (overriddenMember?.id ?? programmeSlot.member?.id) — a listed backup who never actually went on gets no credit, matching the same rule getAnalytics’s memberId filter already uses. Powers the member-facing app’s “Service History” page. Includes the caller’s department slots (asDepartment per entry) and a byDepartment team-performance rollup. |
| GET | /service-session/:sessionCode/state | Public (@Public()) | Get live session state — anchor from Redis + programme data + effectiveSlots (see below); used by presentation, audience, and Programme Manager views |
| GET | /service-session/:sessionCode/slots/:position | Public (@Public()) | Single slot state for speaker view — programmeSlot data, overrides, and current anchor |
| GET | /service-session/:sessionCode/my-status | JwtAuthGuard | The calling member’s personal view of a LIVE session — role (PRIMARY/BACKUP), position, whether it’s currently their turn (isMyTurnNow), whether they’ve already gone (hasPassed), an estimatedSecondsUntilMyTurn (remaining time on the current slot plus the allocated time of every slot in between, null once it’s their turn or already passed), and the full runningOrder. 404 if the caller has no primary or backup slot in the session. Powers the member-facing app’s real-time “my slot” view (/my-assignment/:sessionCode), polled every 8s. Also matches through the caller’s department (asDepartment). |
| GET | /service-session/:sessionCode/report | AdminGuard + SERVICE_PROGRAMME_READ | Formatted session report: duration, completion rate, per-slot overrun, pause log |
| GET | /service-session/:sessionCode/report/pdf | AdminGuard + SERVICE_PROGRAMME_READ | Download session report as a PDF file — same data as JSON report, formatted for printing and sharing |
| GET | /service-session/:sessionCode/pm/report/pdf | Public + ShareTokenGuard (?token=) + NamedAccessGuard (?grantToken=) | Same PDF as above, callable from the public Programme Manager link — surfaced on the manage page’s “Session Ended” screen |
| GET | /service-session/:sessionCode/action-log | JwtAuthGuard (+ assertCanControlSession) | Returns the 10 most recent ServiceActionEntry rows (newest first) as JSON — powers the “Recent Activity” feed on the Live Session Dashboard. Same access tier as the control actions (not admin-only), since it’s operational context, not a compliance artifact. |
| GET | /service-session/:sessionCode/action-log/csv | AdminGuard + SERVICE_PROGRAMME_READ | Download the full ServiceActionEntry audit trail for a session as CSV (Timestamp, Actor Role, Actor, Action, Detail) — admin-only compliance export, distinct from the JSON feed above |
| GET | /service-session/event/:eventId/report/pdf | AdminGuard + SERVICE_PROGRAMME_READ | Download a full-event PDF covering all service slots in one document. Requires all sessions to be COMPLETED; returns 400 if any are still live and 404 if none exist. Includes variance summary table, per-slot allocated vs actual, slot variance (sum of individual slot overruns), and an ACCENT time-summary band per section. |
| GET | /service-session/event/:eventId/report/summary-pdf | AdminGuard + SERVICE_PROGRAMME_READ | Download a shareable one-page event summary PDF (admin access). Does NOT require sessions to be COMPLETED — works at any point after at least one session has started. Contains 4 stat cards (Speakers Done, Total Allocated, Total Actual, Overall Variance) and a single flat table across all services: # | Speaker | Topic/Slot | Allocated | Actual | Variance | Status. Times in MM:SS. Status labels: Over Time (red), Under Time/On Time (green), Not Used/Pending (muted). Returns 404 if no sessions exist. Now wired to a “Summary” button in the Programmes list’s per-event header, shown whenever at least one sub-service is no longer DRAFT (matching this route’s actual requirement, looser than the “Session Report” button’s all-COMPLETED gate). |
| GET | /service-session/event/:eventId/summary-pdf | JwtAuthGuard + WORKER + Admin dept (primary or secondary) | Identical PDF to the admin route above, but accessible by workers in the Admin department (primary or secondary). Enforces assertIsAdminDeptWorker — returns 403 if the authenticated worker is not in the Admin department (no SERVICE_PROGRAMME_WRITE fallback; this check is intentionally separate from assertCanControlSession used by session control). Designed for mobile use: admin-dept workers can download and share the summary immediately after service ends. |
| POST | /service-headcount | AdminGuard + HEADCOUNT_WRITE | Record physical attendance headcount for a service slot (body: serviceSlotId, maleAdults, femaleAdults, teenagers, children, mobileChurch, customGroups?, notes?); upsert — recording again for the same slot edits the existing row. This is the only way to correct a record — the separate PATCH endpoint was removed (see ServiceHeadcount Module notes above). |
| GET | /service-headcount | AdminGuard + HEADCOUNT_READ | List headcount records (query: page, limit, serviceSlotId, from, to); each record includes computed total |
| GET | /service-headcount/trends | AdminGuard + HEADCOUNT_READ | Aggregated attendance trends bucketed by period (query: period=weekly|monthly|quarterly, from, to, serviceSlotName); returns grouped data per slot per bucket |
| GET | /service-headcount/event/:eventId/summary | AdminGuard + HEADCOUNT_READ | Every sub-service under the event with its headcount (or null) plus the aggregate total across recorded sub-services |
| GET | /service-headcount/:id | AdminGuard + HEADCOUNT_READ | Get a single headcount record by ID (includes computed total). Existed with no frontend consumer until now — the Records tab has a “View details” (eye icon) action per row, showing notes/customGroups/recordedBy (none of which the flat table has room for). |
| POST | /service-headcount/export-email | AdminGuard + HEADCOUNT_READ | Email the currently-filtered headcount rows as an .xlsx attachment (body: recipientEmail?, serviceSlotId?, from?, to?). recipientEmail defaults to the requesting admin’s own email. One-off only — not a recurring/scheduled report. Logs REPORT_EXPORTED. |
| GET | /admin/settings | AdminGuard (any admin) | List all known modules with their current enabled status, displayName, and required flag (absent row = enabled by default) |
| GET | /admin/settings/:key | AdminGuard (any admin) | Get one module setting by key (e.g. incident_report, asset_management). Returns required flag. |
| PATCH | /admin/settings/:key | AdminGuard (ADMIN_WRITE) | Enable/disable a module and/or set a displayName override — body: { enabled?: boolean, displayName?: string }. Returns 400 if disabling a required module. Merges rather than overwrites — omitting displayName preserves any previously-set label. Upserts the row, invalidates cache, and writes CHURCH_SETTING_UPDATED audit log. |
| GET | /modules/state | JwtAuthGuard (any authenticated role) | Shared read endpoint: { key, enabled, displayName }[] for every known module. Single source of truth consumed by both frontends for nav/tile visibility and permission-group visibility — see Church Settings Module. |
| POST | /incidents | JwtAuthGuard + Module: incident_report | Submit a new incident report. multipart/form-data. Rate-limited to INCIDENT_DAILY_REPORT_LIMIT (default 2) per member per 24 h. Fields: title, description, location?, isAnonymous? (default false). File field: images (up to 5 image files, max 5 MB each — uploaded to Cloudinary; incident-images folder). Notifies admins with INCIDENT_REPORT_WRITE permission by email. |
| GET | /incidents?page=&limit= | JwtAuthGuard + Module: incident_report | Returns only the current member’s own reports. Members cannot see reports submitted by others. |
| GET | /incidents/:id | JwtAuthGuard + Module: incident_report | Returns a single report only if it was submitted by the current member. Returns 404 otherwise. |
| GET | /admin/incidents?page=&limit=&status=&dateFrom=&dateTo= | AdminGuard (INCIDENT_REPORT_READ) | Paginated list of all incidents. Optional filters: status (OPEN/IN_PROGRESS/RESOLVED), dateFrom and dateTo (ISO date strings, inclusive). Reporter masked to null for anonymous reports. |
| GET | /admin/incidents/:id | AdminGuard (INCIDENT_REPORT_READ) | Get a single incident report with full details. |
| PATCH | /admin/incidents/:id/status | AdminGuard (INCIDENT_REPORT_WRITE) | Update incident status (OPEN → IN_PROGRESS → RESOLVED) and optionally set adminNotes. Sets resolvedAt automatically when status is RESOLVED. |
| GET | /prayer/admin/programs?name= | AdminGuard (PRAYER_READ) | List all prayer programs; optional name param does a case-insensitive partial match |
| POST | /prayer/admin/programs | AdminGuard (PRAYER_WRITE) | Create a prayer program; body: name, audience (WORKERS|MEMBERS|ALL), description?, selectionWindowDays? |
| PATCH | /prayer/admin/programs/:id | AdminGuard (PRAYER_WRITE) | Update a prayer program (any field including isActive) |
| DELETE | /prayer/admin/programs/:id | AdminGuard (PRAYER_WRITE) | Deactivate a prayer program (sets isActive = false) |
| POST | /prayer/admin/programs/:id/clone | AdminGuard (PRAYER_WRITE) | Clone a program: copies all day configs and rules into a new program; body: name, description?, audience?, selectionWindowDays?, includeFixedAssignments? |
| GET | /prayer/admin/config | AdminGuard (PRAYER_READ) | Get the active schedule config (selectionWindowDays) |
| PATCH | /prayer/admin/config | AdminGuard (PRAYER_WRITE) | Upsert the active schedule config |
| GET | /prayer/admin/day-configs?programId= | AdminGuard (PRAYER_READ) | List prayer day configs for a program, ordered by dayOfWeek |
| POST | /prayer/admin/day-configs?programId= | AdminGuard (PRAYER_WRITE) | Create a prayer day config for a program (one active config per day per program) |
| PATCH | /prayer/admin/day-configs/:id | AdminGuard (PRAYER_WRITE) | Update a prayer day config (mode, startTime, endTime, maxCapacity, isActive) |
| GET | /prayer/admin/rules?programId= | AdminGuard (PRAYER_READ) | List schedule rules for a program |
| POST | /prayer/admin/rules?programId= | AdminGuard (PRAYER_WRITE) | Create a schedule rule for a program |
| PATCH | /prayer/admin/rules/:id | AdminGuard (PRAYER_WRITE) | Update a schedule rule (value, isActive, etc.) |
| GET | /prayer/admin/fixed-assignments?programId= | AdminGuard (PRAYER_READ) | List active fixed assignments for a program with worker and day config relations |
| POST | /prayer/admin/fixed-assignments?programId= | AdminGuard (PRAYER_WRITE) | Create a fixed assignment; body: workerProfileId, dayConfigId |
| DELETE | /prayer/admin/fixed-assignments/:id | AdminGuard (PRAYER_WRITE) | Soft-deactivate a fixed assignment |
| POST | /prayer/admin/meetings/generate?programId= | AdminGuard (PRAYER_WRITE) | Generate all meetings for a month for a program; auto-applies fixed assignments; 409 if meetings already exist |
| POST | /prayer/admin/meetings/open-selection?programId= | AdminGuard (PRAYER_WRITE) | Open self-selection window for all PENDING meetings in a month for a program |
| POST | /prayer/admin/meetings/close-selection?programId= | AdminGuard (PRAYER_WRITE) | Close self-selection window for all OPEN meetings in a month for a program |
| POST | /prayer/admin/roster/auto-assign?programId=&month=&year= | AdminGuard (PRAYER_WRITE) | Auto-assign workers to a program’s meetings (clears AUTO_ASSIGNED first for idempotency); returns { assigned, unassignable } |
| POST | /prayer/admin/roster/manual-assign?programId= | AdminGuard (PRAYER_WRITE) | Manually assign a worker or member to a meeting; body: meetingId, workerProfileId? | memberId? |
| DELETE | /prayer/admin/roster/entries/:id | AdminGuard (PRAYER_WRITE) | Remove a SCHEDULED non-FIXED roster entry and decrement meeting capacity |
| GET | /prayer/admin/roster/validate?programId=&month=&year= | AdminGuard (PRAYER_READ) | Validate roster completeness; returns { valid, issues[] } with per-worker frequency and per-meeting leader checks |
| GET | /prayer/admin/roster/:month/:year?programId= | AdminGuard (PRAYER_READ) | Get full monthly roster for a program with all meetings, day configs, and roster entries |
| PATCH | /prayer/admin/roster/entries/:id/reschedule | AdminGuard (PRAYER_WRITE) | Soft-reschedule: marks old entry RESCHEDULED, creates new entry on target meeting with rescheduledFrom FK; body: { newMeetingId } |
| GET | /prayer/programs?name= | WORKER | List active prayer programs scoped to the caller (audience = WORKERS or ALL); optional name param does a case-insensitive partial match; used to obtain a programId before calling meeting/roster endpoints |
| GET | /prayer/available?programId=&month=&year= | WORKER | List open prayer meetings for a program with remaining capacity for the given month |
| GET | /prayer/my-roster?programId=&month=&year= | WORKER | Authenticated worker’s own roster entries for a program in the given month |
| GET | /prayer/my-status?programId=&month=&year= | WORKER | Returns { required, selected, canSubmit, entries } — shows progress toward frequency quota for a program |
| POST | /prayer/select?programId= | WORKER | Self-select a prayer slot; body: { meetingId }; enforced with pessimistic DB lock to prevent overbooking |
| POST | /facility-rental/admin/facilities | AdminGuard (FACILITY_RENTAL_WRITE) | Create a rental facility; body: name, basePrice, description?, capacity? |
| GET | /facility-rental/admin/facilities | AdminGuard (FACILITY_RENTAL_READ) | List all facilities |
| PATCH | /facility-rental/admin/facilities/:id | AdminGuard (FACILITY_RENTAL_WRITE) | Update facility (any field including isActive) |
| POST | /facility-rental/admin/pricing-tiers | AdminGuard (FACILITY_RENTAL_WRITE) | Upsert a pricing tier for a member category; body: memberCategory, discountType, discountValue |
| GET | /facility-rental/admin/pricing-tiers | AdminGuard (FACILITY_RENTAL_READ) | List all pricing tiers |
| DELETE | /facility-rental/admin/pricing-tiers/:id | AdminGuard (FACILITY_RENTAL_WRITE) | Remove a pricing tier |
| POST | /facility-rental/admin/addons | AdminGuard (FACILITY_RENTAL_WRITE) | Create add-on; body: name, price, cautionAmount?, description?, assetId? |
| GET | /facility-rental/admin/addons | AdminGuard (FACILITY_RENTAL_READ) | List active add-ons (with linked asset) |
| PATCH | /facility-rental/admin/addons/:id | AdminGuard (FACILITY_RENTAL_WRITE) | Update add-on |
| POST | /facility-rental/admin/calendar-blocks | AdminGuard (FACILITY_RENTAL_WRITE) | Create admin blackout block; body: facilityId, startDateTime, endDateTime, reason? |
| GET | /facility-rental/admin/calendar-blocks?facilityId= | AdminGuard (FACILITY_RENTAL_READ) | List blackout blocks for a facility |
| DELETE | /facility-rental/admin/calendar-blocks/:id | AdminGuard (FACILITY_RENTAL_WRITE) | Remove a calendar block |
| GET | /facility-rental/admin/bookings?status= | AdminGuard (FACILITY_RENTAL_READ) | List all bookings, optionally filtered by status |
| GET | /facility-rental/admin/bookings/:id | AdminGuard (FACILITY_RENTAL_READ) | Get single booking with addons and payments |
| PATCH | /facility-rental/admin/bookings/:id/confirm | AdminGuard (FACILITY_RENTAL_WRITE) | Confirm a pending booking; optional body: notes |
| PATCH | /facility-rental/admin/bookings/:id/reject | AdminGuard (FACILITY_RENTAL_WRITE) | Reject a pending booking; body: rejectionReason |
| PATCH | /facility-rental/admin/bookings/:id/discount | AdminGuard (FACILITY_RENTAL_WRITE) | Apply override discount; body: overrideDiscountType, overrideDiscountValue, overrideDiscountNote?; recalculates serviceFee and updates SERVICE_FEE payment record |
| DELETE | /facility-rental/admin/bookings/:id/discount | AdminGuard (FACILITY_RENTAL_WRITE) | Remove override discount; reverts to tier-based pricing |
| PATCH | /facility-rental/admin/payments/:id/paid | AdminGuard (FACILITY_RENTAL_WRITE) | Mark a payment as paid; optional body: reference, proofUrl |
| PATCH | /facility-rental/admin/payments/:id/refund | AdminGuard (FACILITY_RENTAL_WRITE) | Mark a caution payment as refunded (must be PAID first) |
| GET | /facility-rental/facilities | JwtAuthGuard | List active facilities (member-facing) |
| GET | /facility-rental/addons | JwtAuthGuard | List active add-ons with linked asset (member-facing) |
| GET | /facility-rental/facilities/:id/availability?from=&to= | JwtAuthGuard | Returns blocked time ranges (bookings + admin blocks) for the facility within a date window |
| POST | /facility-rental/bookings | JwtAuthGuard | Create booking; body: facilityId, startDateTime, endDateTime, purpose?, addons?: [{addonId, quantity}]; overlap-checked; price auto-computed from tier |
| GET | /facility-rental/bookings | JwtAuthGuard | Authenticated member’s own bookings |
| GET | /facility-rental/bookings/:id | JwtAuthGuard | Get own booking detail (returns 404 if belongs to another member) |
| PATCH | /facility-rental/bookings/:id/cancel | JwtAuthGuard | Cancel own booking (only PENDING or CONFIRMED) |
| GET | /admin/notification-templates/push | AdminGuard (admin:read), any plan | Every push type with default and current wording, placeholders and customized, plus customizationAvailable for the church’s plan |
| PUT | /admin/notification-templates/push/:key | AdminGuard (admin:write) + plan notification_customization | Save the church’s title/message for one push type. Body { title, body } |
| DELETE | /admin/notification-templates/push/:key | AdminGuard (admin:write) + plan notification_customization | Reset one push type to the default wording |
| POST | /admin/notification-templates/push/:key/test | AdminGuard (admin:write) + plan notification_customization | Send the draft (or saved) wording to the admin’s own device, filled with the admin’s own recipient details and sample values for the rest; { sent } or { sent: false, reason: 'NO_DEVICE' } |
| GET | /admin/notification-templates/email | AdminGuard (admin:read), any plan | Every customizable email (all member/worker emails) with defaults, current wording, placeholders, lockedNote, customized, plus customizationAvailable |
| POST | /admin/notification-templates/email/:key/preview | AdminGuard (admin:read), any plan | { subject, html } of the email with sample details and the church’s branding; body is an optional unsaved draft |
| PUT | /admin/notification-templates/email/:key | AdminGuard (admin:write) + plan notification_customization | Save wording { subject, heading, message, closing, signoff, signature } |
| DELETE | /admin/notification-templates/email/:key | AdminGuard (admin:write) + plan notification_customization | Reset one email to the default wording |
| POST | /admin/notification-templates/email/:key/test | AdminGuard (admin:write) + plan notification_customization | Send the draft (or saved) email to the admin’s own address, filled with the admin’s own recipient details and sample values for the rest, subject prefixed [Test] |
| GET | /admin/notification-templates/push/:key/history | AdminGuard (admin:read), any plan | Newest-first change history (up to 20) for one push type: [{ id, action, content: { title, body }, changedBy, createdAt }] |
| POST | /admin/notification-templates/push/:key/history/:versionId/restore | AdminGuard (admin:write) + plan notification_customization | Re-save an earlier version’s wording (re-validated, recorded as RESTORED) |
| GET | /admin/notification-templates/email/:key/history | AdminGuard (admin:read), any plan | Newest-first change history (up to 20) for one email, content holding all six wording fields |
| POST | /admin/notification-templates/email/:key/history/:versionId/restore | AdminGuard (admin:write) + plan notification_customization | Re-save an earlier version’s email wording (re-validated, recorded as RESTORED) |
| GET | /notifications/vapid-public-key | JwtAuthGuard | { publicKey } — the server’s VAPID public key; clients must use it as applicationServerKey when subscribing |
| POST | /notifications/subscribe | JwtAuthGuard | Register a Web Push subscription. Called once after first device registration (deviceId transitions from null). Also called after re-registering on a new device following an admin purge or OTP device reset. Body: endpoint, p256dh, auth. Returns 204. |
| DELETE | /notifications/subscribe | JwtAuthGuard | Explicit opt-out: removes the Web Push subscription. Not called on normal logout — subscription persists so the service worker can deliver notifications while the member is logged out. Returns 204. |
| POST | /admin/assets | AdminGuard (ASSET_MANAGEMENT_WRITE) + Module: asset_management | Create a new asset. tagNumber auto-generated (AST-{YEAR}-{NNNN}) if not provided. Optional: serialNumber, manufacturer, model, warrantyExpiry, vendorName, vendorContact, departmentId. Returns 409 if tag already exists. |
| GET | /admin/assets?page=&limit=&status=&category=&maintenanceEnabled=&departmentId= | AdminGuard (ASSET_MANAGEMENT_READ) + Module: asset_management | Paginated asset list. Filterable by status, category (case-insensitive), maintenanceEnabled, and departmentId. Each record includes maintenanceSchedule and department. |
| GET | /admin/assets/checkouts?page=&limit= | AdminGuard (ASSET_MANAGEMENT_READ) + Module: asset_management | All currently active checkouts across all assets (returnedAt IS NULL), newest first. |
| GET | /admin/assets/:id | AdminGuard (ASSET_MANAGEMENT_READ) + Module: asset_management | Get asset with maintenanceSchedule and department. Maintenance history is paginated separately. |
| PATCH | /admin/assets/:id | AdminGuard (ASSET_MANAGEMENT_WRITE) + Module: asset_management | Partial update. Supports all asset fields including serialNumber, manufacturer, model, warrantyExpiry, vendorName, vendorContact, departmentId. |
| POST | /admin/assets/:id/maintenance-schedule | AdminGuard (ASSET_MANAGEMENT_WRITE) + Module: asset_management | Set or update the maintenance schedule. Sets maintenanceEnabled = true. Resets all notification timestamps. Body: frequencyUnit, frequencyValue, nextDueAt. |
| POST | /admin/assets/:id/maintenance-records | AdminGuard (ASSET_MANAGEMENT_WRITE) + Module: asset_management | Log a maintenance record. COMPLETED → asset ACTIVE + recalculates nextDueAt. IN_PROGRESS → asset UNDER_MAINTENANCE. |
| PATCH | /admin/assets/:id/inventory | AdminGuard (ASSET_MANAGEMENT_WRITE) + Module: asset_management | Set inventory breakdown. Sets inventoryEnabled = true. Body: inStorage, inUse, underRepair, writtenOff (all int ≥ 0). totalUnits = sum of all four. |
| GET | /admin/assets/:id/maintenance-records?page=&limit= | AdminGuard (ASSET_MANAGEMENT_READ) + Module: asset_management | Paginated maintenance history for an asset, newest first. |
| POST | /admin/assets/:id/checkouts | AdminGuard (ASSET_MANAGEMENT_WRITE) + Module: asset_management | Check out an asset. Requires checkedOutToMemberId or checkedOutToDepartmentId (at least one). Optional: expectedReturnAt, purpose, notes. Returns 400 if asset already has an active checkout, or asset is UNDER_MAINTENANCE, DECOMMISSIONED, or INACTIVE. On success: email notification sent to the checked-out member (if member checkout) and/or all HOD/D_HOD leads of the target department (if department checkout) via the asset-checkout-notification template. Notifications are fire-and-forget. |
| PATCH | /admin/assets/:id/checkouts/:checkoutId/return | AdminGuard (ASSET_MANAGEMENT_WRITE) + Module: asset_management | Mark a checkout as returned. Optional body: notes. Returns 400 if already returned. On success: email notification sent to the original recipient (member or department HOD/D_HOD leads) confirming the return. A RETURN_CONFIRMED row is recorded in asset_checkout_notifications. |
| GET | /admin/assets/:id/checkouts?page=&limit= | AdminGuard (ASSET_MANAGEMENT_READ) + Module: asset_management | Paginated checkout history for a specific asset, newest first. |
Overdue checkout reminders (daily cron at 08:00): OverdueCheckoutScheduler runs every day at 08:00 with a distributed Redis lock. It finds all active checkouts (returnedAt IS NULL) where expectedReturnAt < now. For each, it checks which day-thresholds defined in ASSET_OVERDUE_NOTIFICATION_DAYS have not yet been sent (tracked in the asset_checkout_notifications table with type = OVERDUE_REMINDER). Notifications go to the checked-out member and/or all HOD/D_HOD leads of the checked-out department. Set ASSET_OVERDUE_NOTIFICATION_DAYS= (empty) to disable all overdue reminders.
7. Check-In Flow
POST /attendances/checkin
Body: { serviceSlotId, location? }
Step-by-step:
-
Load slot — fetches
ServiceSlotwith relationsevent,config,config.defaultVenue,venueOverride. Throws 404 if not found. -
Load member — fetches the authenticated member with
workerProfile. -
Assert active — throws 400 if
member.status = INACTIVE. Also throws if the member is a WORKER withworkerProfile.status = INACTIVE. -
Resolve config —
EventService.resolveSlotConfig(slot)merges per-slot overrides over EventConfig values, includingformat(slot.formatOverride ?? config.defaultFormat). Throws 400 if no config, or if the resolvedformatisIN_PERSONwith no resolvable venue. -
Worker location — workers must provide
locationcoordinates, but only when the resolvedformatisIN_PERSON. AnONLINE-resolved slot never requires location from anyone. Throws 400 iflocationis absent for a WORKER checking into anIN_PERSONslot. -
Duplicate check — throws 400 if an attendance record already exists for
(member, event). One record per event, regardless of which slot the member enters. -
Validate window:
- Workers: window opens at
startTime + workerCheckinStartOffsetSeconds(typically negative) - Members: window opens at
startTime + memberCheckinStartOffsetSeconds - Both close at
startTime + checkinStopOffsetSeconds
- Workers: window opens at
-
Validate location (if location provided AND the resolved venue is non-null): Calculates Haversine distance between submitted coordinates and the venue’s
latitude/longitude. If distance exceedsallowedDistanceInMetersand enforcement is on for this tenant (AttendanceSettingsService.isEnabled()— see “Attendance Distance Check Setting” below; no longer a single globalENFORCE_DISTANCE_CHECK=truefor every tenant), throws 400. Never runs for anONLINE-resolved slot, since its resolved venue is null. -
Resolve status:
- Member → always
PRESENT - Worker before late threshold →
PRESENT - Worker at or after
startTime + workerLateOffsetSeconds→LATE
- Member → always
-
Save record — creates
Attendancewith references to botheventandserviceSlot,roleAtCheckinsnapshot, and optional location.
8. Automated Absence Marking
A cron job runs every 5 minutes (EVERY_5_MINUTES).
Logic:
- Finds all
Eventrecords whereattendanceMarked = falseANDendTime < now(the precise instant, not the date-onlyendDate) AND the event has at least one service slot. Served by a partial index (IDX_events_end_time_unmarked,end_time WHERE attendance_marked = false) so the query stays cheap regardless of how much event history a tenant accumulates — a plain index onend_timealone would match nearly every past event, not just the small rolling set still awaiting marking. - For each event:
- Gets all members (ACTIVE, role=MEMBER) who have no
PRESENTorLATEattendance record for the event → creates oneABSENTrecord per member referencing the event (serviceSlot = null). - Gets all workers (ACTIVE, role=WORKER) who have no
PRESENTorLATErecord for the event:- Checks
request_leavetable: if the worker has an APPROVED leave whosedate_from ≤ event.eventDate ≤ date_to→ createsON_LEAVErecord.- PostgreSQL
DATEvalues may be hydrated asYYYY-MM-DDstrings; leave-date comparisons preserve that form (and normalizeDatevalues) to avoid timezone shifts.
- PostgreSQL
- Otherwise → creates
ABSENTrecord.
- Checks
- Gets all members (ACTIVE, role=MEMBER) who have no
- All absence records for the event are saved in a single DB transaction.
- Sets
event.attendanceMarked = trueso the job skips it next run. - Dispatches a
post-eventjob to thefollow-upBull queue for thank-you emails and optional online-confirm notifications (fire-and-forget, inside the loop but outside the transaction).
9. Role & Permission Matrix
The system has two distinct access dimensions:
- Church role (
MemberRoleEnumon the Member entity) — controls mobile-app routes:MEMBERorWORKER. - Admin portal access (
Adminentity +AdminRolepermissions) — controls admin web portal routes viaAdminGuard.
A church worker can also have admin access. They pass @Roles(WORKER) routes via their church role and pass
@UseGuards(AdminGuard) routes via their Admin record.
Mobile App (church role)
| Action | MEMBER | WORKER |
|---|---|---|
| Sign up / login | ✓ | ✓ |
| View own profile | ✓ | ✓ |
| Check in to service | ✓ | ✓ |
| View own attendance | ✓ | ✓ |
| View own class enrolments | ✓ | ✓ |
| View announcement feed | ✓ | ✓ |
| Worker dashboard | — | ✓ |
| Request leave | — | ✓ |
| View own leave history | — | ✓ |
| View department leave | — | ✓ (lead only) |
| SS class actions (create/update/assign members) | — | ✓ (SS-dept or class teacher) |
| SS session management | — | ✓ (SS-dept or class teacher) |
| SS self-mark attendance | enrolled member | enrolled member |
| SS bulk-mark / roster | — | ✓ (SS-dept or class teacher) |
| CC child/guardian management | — | ✓ (CC-dept worker) |
| CC check-in / check-out / flag | — | ✓ (CC-dept worker) |
| Register first-timers | — | ✓ (FOLLOW_UP-dept worker) |
| View / update own follow-up tasks | — | ✓ (FOLLOW_UP-dept worker) |
| Confirm online attendance | ✓ | ✓ |
| Submit a prayer request / testimony | ✓ | ✓ |
| View public testimony feed | ✓ | ✓ |
| Prayer team inbox (view/update request status) | — | ✓ (PRAYER-dept worker or Clergy) |
| Rate a service (own rating only) | ✓ | ✓ |
| Browse and sign up for volunteer opportunities | ✓ | ✓ |
| Browse, join, and leave fellowships | ✓ | ✓ |
| Record attendance for a fellowship (leader only) | ✓ (if leader) | ✓ (if leader) |
Admin Portal (AdminGuard + permission)
| Action | Permission |
|---|---|
| List / view members | MEMBERS_READ |
| Create a member account directly | MEMBERS_WRITE |
| Promote / revoke workers, reset passwords | MEMBERS_WRITE |
| View events / configs | EVENTS_READ |
| Create / update / delete events & configs | EVENTS_WRITE |
| Create / update / delete venues | VENUES_WRITE |
| View departments / leads | DEPARTMENTS_READ |
| Create / update / delete departments & leads | DEPARTMENTS_WRITE |
| View all attendance, leaderboard | ATTENDANCE_READ |
| Correct an attendance record status | ATTENDANCE_WRITE |
| Mark/backfill attendance for a member (admin portal) | ATTENDANCE_WRITE |
| View all leave requests | LEAVE_READ |
| Approve / reject leave | LEAVE_WRITE |
| View classes & enrolments | CLASSES_READ |
| Create / update / delete classes & enrolments | CLASSES_WRITE |
| View announcements | ANNOUNCEMENTS_READ |
| Create / update / delete announcements | ANNOUNCEMENTS_WRITE |
| View pastoral notes & analytics | NOTES_READ |
| Create / update / delete notes | NOTES_WRITE |
| Admin dashboard | DASHBOARD_READ |
| SS delete class/session | SUNDAY_SCHOOL_WRITE |
| CC age/class group CRUD + recompute | CHILDREN_CHURCH_WRITE |
| CC slot-level check-in report | CHILDREN_CHURCH_READ |
| View audit logs | AUDIT_READ |
| View admin users & roles | ADMIN_READ |
| Create / update / delete admin users & roles | ADMIN_WRITE |
| View own admin profile | (any active admin) |
| View tithe batches, records, disputes | FINANCE_READ |
| Upload tithes, resolve disputes, approve/reject requests, attach proof | FINANCE_WRITE |
| View finance categories and requests | FINANCE_READ |
| View first-timers and follow-up tasks | FOLLOW_UP_READ |
| Register first-timers, reassign / bulk-update tasks | FOLLOW_UP_WRITE |
| View service attendance headcounts and trends | HEADCOUNT_READ |
| Record and correct physical attendance headcounts | HEADCOUNT_WRITE |
| View prayer config, rules, roster, and meetings | PRAYER_READ |
| Manage prayer days, rules, assignments, and roster | PRAYER_WRITE |
| View prayer requests and testimonies | PRAYER_READ |
| Update a prayer request’s status | PRAYER_WRITE |
| View pregnancy prayer cases | PRAYER_READ |
| Update a pregnancy prayer case’s status | PRAYER_WRITE |
| View evangelism converts and follow-up history | EVANGELISM_READ |
| Reassign convert follow-up, link convert to member | EVANGELISM_WRITE |
| View sermon archive entries | SERMON_READ |
| Create/edit/delete sermons, trigger “we’re live” | SERMON_WRITE |
| View games, questions, sessions, and leaderboards | GAMES_READ |
| Create/edit games and questions, control live sessions | GAMES_WRITE |
| View aggregate service ratings and anonymized comments | SERVICE_RATING_READ |
| Reveal identity behind a rating comment; delete/hide it | SERVICE_RATING_MODERATE |
| View volunteer opportunities and sign-up rosters | VOLUNTEER_READ |
| Create, edit, and cancel volunteer opportunities | VOLUNTEER_WRITE |
| View small groups, rosters, and attendance history | SMALL_GROUP_READ |
| Create, edit, delete small groups; assign leaders; remove members | SMALL_GROUP_WRITE |
10. Environment Variables
All variables are validated by Joi at startup (src/config/env.validation.ts). Missing required variables crash the
process with a clear error before any HTTP traffic is accepted.
For the database backup/restore strategy (not an env var concern, but adjacent operational documentation that
didn’t exist anywhere before), see docs/BACKUP_AND_RESTORE.md.
Graceful shutdown: main.ts calls app.enableShutdownHooks(['SIGTERM', 'SIGINT']), so in-flight requests are
allowed to finish and NestJS lifecycle hooks (e.g. closing the DB pool, Redis, Bull queues) run before the process
exits. Relevant when the orchestrator sends SIGTERM on deploy/scale-down — without this, connections would be cut
mid-request.
Provider webhooks bypass the global JWT guard: the YouTube WebSub
callbacks (GET/POST /integrations/youtube/callback), POST /webhooks/billing, and
POST /webhooks/giving/:tenantId/:provider are decorated @Public(). Providers never send a bearer token, so the
global JwtAuthGuard would 401 them before their own signature verification (HMAC / X-Hub-Signature /
x-paystack-signature / verif-hash / x-korapay-signature / Stripe-Signature) ever runs. @Public() only opts
a route out of JWT auth — it does not skip the handler’s own signature check. All three are also excluded from
TenantMiddleware (§4.3) — same no-Host-header reasoning, though the giving webhook is the one exception with an
actual tenant identifier on the route itself (:tenantId), since unlike billing’s single shared platform-wide
route, each tenant has their own BYOK giving-checkout credentials to resolve.
Migration history was squashed (2026-07-31): the 107 incremental migrations that had accumulated since the
project’s first commit were replaced with a single src/migrations/1790553600000-Baseline.ts, generated via
pg_dump --schema-only (plus a --data-only dump of the static reference tables: admin_roles, class_types,
prayer_programs, prayer_schedule_rules) against a database that had every prior migration applied. The baseline
was verified to produce a byte-identical schema and seed dataset before the switch. The original files are kept in
src/migrations/legacy/ for historical reference — that folder is outside the glob TypeORM scans
(src/data-source.ts’s migrations path is non-recursive), so they no longer run. This was a pre-production
one-time cleanup; per CLAUDE.md, no migration is ever edited or re-squashed after it has shipped
to a real environment.
Runtime
| Variable | Default | Description |
|---|---|---|
NODE_ENV |
development |
development | production | test |
PORT |
3000 |
HTTP port the server listens on |
CORS_ORIGINS |
— (required) | Comma-separated extra CORS allowlist for origins outside APP_BASE_DOMAIN (marketing site, docs, ops tooling) — every subdomain of APP_BASE_DOMAIN is allowed dynamically regardless of this list, see “CORS origin validation” under Multi-Tenant Request Scoping |
APP_NAME |
discuva-api |
Service name used in logs and process identification |
APP_BASE_DOMAIN |
localhost |
Suffix TenantMiddleware strips from the Host header to find a tenant’s subdomain (§5 Multi-Tenant Request Scoping) — *.localhost resolves to 127.0.0.1 with no /etc/hosts changes, so the default works out of the box in dev |
Branding (used in email templates and generated PDFs)
PRODUCT_NAME is genuinely platform-wide (the SaaS product name) and is always read from here. CHURCH_NAME/
CHURCH_ADDRESS/CHURCH_TAGLINE/LOGO_URL/CURRENCY_CODE are now only the fallback for a tenant that hasn’t
set its own name/address/tagline/logoUrl/currency (per-field, not all-or-nothing) — see
EmailQueueService.resolveBrandingData(), PdfService.resolveBranding(), and TenantCurrencyService.resolveCurrencyCode()
under Utility/Infrastructure above. All three share the same tenant-branding:${tenantId} cache entry (one Tenant
lookup serves all three). TenantCurrencyService is also used by FinanceRequestService (Excel export header,
approve/reject/submitted notification emails) and AnnualGivingStatementScheduler — both sendForMember() (the
on-demand POST /finance/me/giving-statement/send path, which always has real CLS context from its HTTP caller)
and the nightly @Cron path, run(), which now resolves each active tenant’s own currency correctly since
sendAnnualStatements() wraps run() in forEachActiveTenant() (see “Scheduler tenant iteration” under
Multi-Tenant Request Scoping) — every @Cron scheduler that touches tenant-scoped data now loops per tenant.
CURRENCY_LOCALE has no tenant-scoped equivalent (Tenant has no locale column) and stays a pure global default —
used by PdfService for number formatting and, unrelatedly, by EventReminderService/TitheService for date/time
formatting (those two never touched currency, so needed no change).
Every generated PDF’s header now embeds the tenant’s actual logo, not just its name/tagline text.
PdfService.resolveBranding() fetches tenant.logoUrl (when set) and base64-encodes it into PdfBranding.logoImage
once per PDF — drawPageHeader() (shared by every report type: session/event reports, department goals, giving
statements, etc.) then calls jsPDF’s addImage() with it, shifting the church name/tagline right to make room.
Cached by URL under pdf-logo-image:${logoUrl} (same TTL/mechanism as the branding cache) so a broken or
unreachable logo URL doesn’t retry on every PDF request — it just caches the resulting null and falls back to
the original text-only header. Only PNG/JPEG are embeddable (jsPDF’s addImage needs a format jsPDF can decode);
an SVG or unsupported logo format also falls back to text-only rather than failing PDF generation. The
addImage() call itself is wrapped in its own try/catch too, so a corrupt image can’t break the rest of the
header/report either.
| Variable | Default | Description |
|---|---|---|
PRODUCT_NAME |
Discuva |
Product name shown in email subjects — always global |
CHURCH_NAME |
RCCG Discovery Centre |
Fallback when a tenant’s own name is unset |
CHURCH_ADDRESS |
62 Igi Olugbin Street, Bariga. Lagos, Nigeria |
Fallback when a tenant’s own address is unset |
CHURCH_TAGLINE |
Destinies discovered, Champions raised |
Fallback when a tenant’s own tagline is unset — PDFs only |
LOGO_URL |
Cloudinary default logo asset | Fallback when a tenant’s own logoUrl is unset |
CURRENCY_CODE |
NGN |
Fallback when a tenant’s own currency is unset |
CURRENCY_LOCALE |
en-NG |
Always global — no tenant-scoped equivalent exists |
Error Tracking (Sentry)
Optional. src/instrument.ts is imported as the very first line of main.ts (before any other import — required
for the SDK’s automatic instrumentation of http/pg/etc. to attach before those modules are first loaded
elsewhere in the dependency graph) and calls Sentry.init() only when both SENTRY_DSN is set and
SENTRY_ENABLED is true. HttpExceptionFilter (the single global exception filter) calls
Sentry.captureException() only for 5xx responses and genuinely unhandled (non-HttpException) errors — routine
4xx validation/auth errors are never reported, matching the filter’s existing error/warn log-level split.
Sentry.captureException() is safe to call even when init() never ran (unset DSN) — it’s a no-op, not a crash.
| Variable | Default | Description |
|---|---|---|
SENTRY_DSN |
— (unset) | Sentry project DSN. Unset disables error reporting entirely — the default for local dev. |
SENTRY_ENABLED |
true |
Separate kill switch on top of SENTRY_DSN — set to false to mute reporting without removing the DSN. |
SENTRY_ENVIRONMENT |
NODE_ENV |
Sentry environment tag. Falls back to NODE_ENV, then 'development'. |
Database
| Variable | Default | Description |
|---|---|---|
DATABASE_HOST |
— (required) | Postgres host |
DATABASE_PORT |
5432 |
Postgres port |
DATABASE_USER |
— (required) | DB username |
DATABASE_PASSWORD |
— (required) | DB password |
DATABASE_NAME |
— (required) | DB name |
DATABASE_SSL |
false |
Enable SSL (rejectUnauthorized=false) |
DATABASE_LOGGING |
false |
Enable TypeORM query logging |
DATABASE_DEBUG |
false |
Enable TypeORM debug-level query logging |
DATABASE_POOL_SIZE |
50 |
Max connections in the pool |
DATABASE_POOL_MIN |
0 |
Min idle connections kept alive. 0 lets the database scale to zero when the app is quiet |
DATABASE_POOL |
transaction |
Pool mode for PgBouncer/Supavisor: transaction | session | statement |
DATABASE_POOL_LOG |
false |
Log pool connection acquire/release events |
JWT
| Variable | Default | Description |
|---|---|---|
JWT_SECRET |
— (required, min 32 chars) | Access token signing secret |
JWT_EXPIRY_IN |
1h |
Access token expiry (e.g. 1h, 15m, 7d) |
REFRESH_JWT_SECRET |
— (required, min 32 chars) | Refresh token signing secret |
REFRESH_JWT_EXPIRY_IN |
7d |
Refresh token expiry |
SESSION_MAX_AGE_DAYS |
30 |
Absolute session lifetime in days — refresh rejected after this regardless of rotation |
PLATFORM_ADMIN_JWT_SECRET |
— (required, min 32 chars) | Platform-admin token signing secret — deliberately separate from JWT_SECRET (§5 Platform Admin) |
PLATFORM_ADMIN_JWT_EXPIRY_IN |
1h |
Platform-admin token expiry |
PLATFORM_ADMIN_REFRESH_JWT_SECRET |
— (required, min 32 chars) | Platform-admin refresh-token signing secret — separate from both PLATFORM_ADMIN_JWT_SECRET and the tenant-side REFRESH_JWT_SECRET |
PLATFORM_ADMIN_REFRESH_JWT_EXPIRY_IN |
7d |
Platform-admin refresh-token expiry — how long a session survives without a fresh password login |
CREDENTIALS_ENCRYPTION_KEY |
— (required, min 32 chars) | Encrypts tenant BYOK SMS/email provider credentials at rest (§5 Communication Providers) — rotating this makes existing encrypted credentials unreadable |
Set EMAIL_PROVIDER to choose the platform-wide default provider. This is only the fallback used when a tenant has
no BYOK config of its own (see Communication Providers) — a tenant can independently pick any of the five providers
below regardless of this setting. Only the variables for the active default provider are required at runtime;
SmtpProvider has no platform default at all (BYOK-only).
| Variable | Default | Description |
|---|---|---|
EMAIL_PROVIDER |
gmail |
Platform-default provider: gmail | resend |
EMAIL_FROM |
— | Sender address used for all outbound email (overrides EMAIL_USER) |
Gmail SMTP
| Variable | Default | Description |
|---|---|---|
EMAIL_HOST |
— | SMTP host |
EMAIL_PORT |
— | SMTP port |
EMAIL_SECURE |
false |
true for port 465, false for 587 |
EMAIL_SERVICE |
— | e.g. gmail (optional if HOST/PORT are set) |
EMAIL_USER |
— | SMTP username / sender address |
EMAIL_PASSWORD |
— | SMTP password / app password |
Resend
| Variable | Default | Description |
|---|---|---|
RESEND_API_KEY |
— | Resend API key (re_*…) |
Custom SMTP
No platform-default env vars — this provider is BYOK-only (providerId: 'smtp') and throws if called without a
tenant’s own {host, port?, secure?, user, password} credentials. Use this when a tenant wants to fully bring their
own mail server; use gmail’s BYOK host override instead when they just want a different domain on otherwise
platform-managed SMTP settings.
SendGrid
| Variable | Default | Description |
|---|---|---|
SENDGRID_API_KEY |
— | SendGrid API key, platform default |
SENDGRID_BASE_URL |
https://api.sendgrid.com |
Override for region failover / test doubles — matches every sibling provider’s own *_BASE_URL convention |
Mailgun
| Variable | Default | Description |
|---|---|---|
MAILGUN_API_KEY |
— | Mailgun API key, platform default |
MAILGUN_DOMAIN |
— | Mailgun sending domain, platform default |
MAILGUN_BASE_URL |
https://api.mailgun.net/v3 |
Override for the EU region (https://api.eu.mailgun.net/v3) |
Email Category Gates
Each flag defaults to true. Set to false to suppress that category of emails (useful when on Resend’s free tier to stay under the daily limit). Auth and admin emails are not gated.
| Variable | Default | Category suppressed |
|---|---|---|
EMAIL_ATTENDANCE_CHECKIN_ENABLED |
true |
Attendance check-in receipts |
EMAIL_BIRTHDAY_ENABLED |
true |
Birthday greetings |
EMAIL_EVENT_REMINDER_ENABLED |
true |
Event slot reminders |
EMAIL_PRAYER_REMINDER_ENABLED |
true |
Prayer roster reminders |
EMAIL_FOLLOW_UP_ENABLED |
true |
Follow-up task emails |
EMAIL_ASSET_ALERTS_ENABLED |
true |
Asset maintenance/overdue alerts |
EMAIL_GIVING_RECEIPT_ENABLED |
true |
Tithe receipts and statements |
EMAIL_FINANCE_ALERTS_ENABLED |
true |
Budget and pledge alerts |
EMAIL_SESSION_REPORT_ENABLED |
true |
Session completion reports |
EMAIL_INCIDENT_REPORT_ENABLED |
true |
Incident report notifications |
EMAIL_CHILDREN_CHURCH_ENABLED |
true |
Children church pickup codes |
EMAIL_LOGIN_ALERT_ENABLED |
true |
New device login notifications |
EMAIL_PASTOR_FEEDBACK_ENABLED |
true |
Weekly feedback reminders and pastor-response notifications |
EMAIL_ASSIGNMENT_REMINDER_ENABLED |
true |
Assignment due-date reminders |
EMAIL_CLASS_SESSION_REMINDER_ENABLED |
true |
Class next-session reminders |
EMAIL_FORM_SUBMISSION_ENABLED |
true |
Admin notification on a new form submission (also requires the form’s own notifyOnSubmission to be on) |
EMAIL_SUNDAY_SCHOOL_QA_ENABLED |
true |
Sunday School question-asked / question-answered notifications |
EMAIL_SUNDAY_SCHOOL_ATTENDANCE_ENABLED |
true |
Sunday School check-in-open and weekly absentee pushes |
EMAIL_TRAINING_CLASSES_ENABLED |
true |
Training class join-request decisions and certificate-ready pushes |
EMAIL_EVANGELISM_ENABLED |
true |
Evangelism outreach-team and convert-assignment pushes |
EMAIL_NOTES_ENABLED |
true |
Notes reminder pushes (evening after a service, Monday weekly step) |
EMAIL_DEPARTMENT_GOAL_ACTIVITY_ENABLED |
true |
Department Goals approval decisions and comments (push-only for now — the flag exists for consistency and to gate a future email leg) |
Auth / OTP
| Variable | Default | Description |
|---|---|---|
OTP_TTL_SECONDS |
900 |
How long a reset OTP stays valid (15 min) |
FORGOT_PASSWORD_MAX_ATTEMPTS |
3 |
Max OTP requests per rate-limit window |
FORGOT_PASSWORD_WINDOW_SECONDS |
3600 |
Rate-limit window for forgot-password (1 hr) |
LOGIN_MAX_ATTEMPTS |
5 |
Max failed login attempts before lockout |
LOGIN_WINDOW_SECONDS |
900 |
Lockout window duration (15 min) |
DEVICE_RESET_MAX_ATTEMPTS |
3 |
Max self-service device reset requests per window per email |
DEVICE_RESET_WINDOW_SECONDS |
86400 |
Rate-limit window for device resets (24 hr) |
OTP_VERIFY_MAX_ATTEMPTS |
5 |
Max wrong-code guesses per account against a live OTP (password reset, device reset, email change) before a 429 lockout for the rest of that OTP’s OTP_TTL_SECONDS window — separate from FORGOT_PASSWORD_MAX_ATTEMPTS/DEVICE_RESET_MAX_ATTEMPTS, which only cap how often a new code can be requested |
Global Rate Limiting
Applied to every endpoint via ThrottlerGuard as a global APP_GUARD. Returns HTTP 429 when exceeded. The
GET /health endpoint is exempt via @SkipThrottle().
| Variable | Default | Description |
|---|---|---|
THROTTLE_TTL_MS |
60000 |
Sliding window in milliseconds (1 min) |
THROTTLE_LIMIT |
100 |
Max requests per window per IP |
Redis
Used for two purposes: the distributed cache (CacheService) and the Bull email job queue (EmailQueueService).
Both use the same Redis server and the same logical database — Bull keys are namespaced bull:* and do not collide
with application cache keys.
| Variable | Default | Description |
|---|---|---|
REDIS_HOST |
localhost |
Redis server hostname |
REDIS_PORT |
6379 |
Redis server port |
REDIS_PASSWORD |
— | Redis auth password (leave blank if no auth) |
REDIS_DB |
0 |
Logical database index (0–15) |
Timezone
| Variable | Default | Description |
|---|---|---|
TIMEZONE |
Africa/Lagos |
IANA timezone name. Drives two things: (1) every daily/specific-time @Cron job below runs in this timezone via its timeZone option, not the server process’s own clock; (2) DateService.startOfDay()/endOfDay() (used by getTotalCheckInsToday()) compute day boundaries in this timezone. Does not change process.env.TZ — the server process itself still runs in whatever timezone its host/container is set to (UTC in this deployment); only these two call sites are timezone-aware. All “runs daily at HH:MM” times documented below are in this configured timezone. |
Cache TTLs
| Variable | Default | Description |
|---|---|---|
CACHE_TTL_REFERENCE_SECONDS |
300 |
TTL for reference data: departments, venues, event configs (5 min) |
CACHE_TTL_LEADERBOARD_SECONDS |
90 |
TTL for attendance leaderboard |
Birthday Wishes
| Variable | Default | Description |
|---|---|---|
WISH_DAILY_LIMIT |
20 |
Max birthday wishes a single user can send per day |
Attendance / Check-In
| Variable | Default | Description |
|---|---|---|
ENFORCE_DISTANCE_CHECK |
false |
No longer read directly by AttendanceService — now only the fallback-of-the-fallback for PlatformSettingKey.ENFORCE_DISTANCE_CHECK_DEFAULT (see “Attendance Distance Check Setting” below) when no PlatformSetting row exists yet either. Kept as a real env var (not removed) specifically so an environment that already has it set isn’t silently reset to false the moment this shipped. |
ONLINE_CHECKIN_WINDOW_HOURS |
3 |
Default hours after online-confirm emails are sent during which members can confirm online attendance; each church can override it in minutes (Event Config → Online Attendance Confirmation, 15 min – 7 days); the email shows it as e.g. “2 hours 30 minutes” (window_label) |
FOLLOW_UP_DUE_DAYS |
3 |
Days from task creation before a follow-up task is considered overdue (sets dueDate) |
FOLLOW_UP_STALE_DAYS |
7 |
Days of inactivity before an open task is flagged stale (daily cron + stale endpoint) |
Service Programme
| Variable | Default | Description |
|---|---|---|
SERVICE_SLOT_CAUTION_THRESHOLD_RATIO |
0.25 |
Fraction of a slot’s allocated time remaining at which the presentation view switches to the “Wrapping Up” caution state. Resolved server-side and returned as cautionThresholdRatio on GET /service-session/:code/state — never duplicated as frontend config. |
Default Seed Data (applied on first boot)
| Variable | Default | Description |
|---|---|---|
DEFAULT_ADMIN_EMAIL |
— | Email for the seeded default admin account |
DEFAULT_ADMIN_PASSWORD |
— | Password for the seeded default admin account |
DEFAULT_PLATFORM_ADMIN_EMAIL |
— | Email for the seeded first platform admin (npm run seed:platform-admin) |
DEFAULT_PLATFORM_ADMIN_PASSWORD_HASH |
— | Argon2 hash for the seeded first platform admin (generate via npm run hash:password) |
Removed: DEFAULT_VENUE_NAME/DEFAULT_VENUE_ADDRESS/DEFAULT_VENUE_LATITUDE/DEFAULT_VENUE_LONGITUDE/
DEFAULT_EVENT_CONFIG_NAME/DEFAULT_EVENT_ALLOWED_DISTANCE_IN_METERS/WORKER_CHECKIN_START_OFFSET_SECONDS/
WORKER_LATE_OFFSET_SECONDS/MEMBER_CHECKIN_START_OFFSET_SECONDS/CHECKIN_STOP_OFFSET_SECONDS — these only ever
fed DefaultEventConfigSeed, a boot-time seed that ran outside any tenant CLS context and wrote into orphaned
public.venues/public.event_config rows no live tenant schema ever reads (each tenant gets its own venues/
event_config rows via TenantSchemaGenesis/normal admin setup instead). Confirmed dead — deleted along with the
seed service rather than fixed. The four checkin-offset names now live only as columns on the tenant-scoped
EventConfig entity (workerCheckinStartOffsetSeconds etc.), editable per-tenant via EventConfigController.
Cloudinary (file uploads)
Used for finance request attachments and payment proofs.
| Variable | Default | Description |
|---|---|---|
CLOUDINARY_CLOUD_NAME |
— (required) | Cloudinary account cloud name |
CLOUDINARY_API_KEY |
— (required) | Cloudinary API key |
CLOUDINARY_API_SECRET |
— (required) | Cloudinary API secret |
MAX_FILE_UPLOAD_BYTES |
5242880 |
Fallback default for routes with no more specific category — incident report photos, member bulk-import spreadsheets |
Logo/appearance, avatar, class-material, finance-proof, form-attachment, and page-image upload limits are not
env vars — they’re platform-admin-configurable via PlatformSettingKey.MAX_LOGO_UPLOAD_MB/MAX_AVATAR_UPLOAD_MB/
MAX_CLASS_MATERIAL_UPLOAD_MB/MAX_FINANCE_PROOF_UPLOAD_MB/MAX_FORM_ATTACHMENT_UPLOAD_MB/
MAX_PAGE_IMAGE_UPLOAD_MB (see “Platform Settings” under the platform-admin
section) and enforced via DynamicLimitedFileInterceptor, which rewrites Multer’s generic “File too large” error
into "The uploaded file exceeds the maximum allowed size of {N} MB..." using the live limit for that route —
not a guess. HttpExceptionFilter’s own PayloadTooLargeException handling (using MAX_FILE_UPLOAD_BYTES) is a
fallback only, for the handful of upload routes not wrapped in LimitedFileInterceptor/DynamicLimitedFileInterceptor
at all (e.g. finance-admin/tithe-admin/reconciliation attachment routes, which currently have no configured
size limit).
| TITHE_PROOF_EXPIRY_DAYS | 90 | Days after which a tithe payment proof is purged from Cloudinary and DB |
| ASSET_OVERDUE_NOTIFICATION_DAYS | 1,3,7 | Comma-separated day thresholds for overdue checkout reminders. Leave empty to disable. |
Web Push (VAPID)
Generate keys once with npx web-push generate-vapid-keys and store permanently.
| Variable | Default | Description |
|---|---|---|
VAPID_PUBLIC_KEY |
— (required) | VAPID public key — also exposed to the PWA frontend as NEXT_PUBLIC_VAPID_PUBLIC_KEY |
VAPID_PRIVATE_KEY |
— (required) | VAPID private key — backend only, never exposed to clients |
VAPID_SUBJECT |
— (required) | VAPID subject — must be a mailto: or https:// URI (e.g. mailto:admin@example.com) |
App URLs (embedded in emails)
| Variable | Description |
|---|---|
LOGIN_URL |
Mobile app login URL — embedded in member/worker welcome and notification emails (required) |
ADMIN_LOGIN_URL |
Admin portal login URL — embedded in the admin welcome email on role grant (required) |
SUPPORT_FORM_URL |
Support contact form URL |
EXPLAINER_VIDEO_ANDROID_URL |
Android onboarding video URL |
EXPLAINER_VIDEO_IOS_URL |
iOS onboarding video URL |
Bull Board (optional)
| Variable | Default | Description |
|---|---|---|
BULL_BOARD_USER |
— | Username for the Bull Board queue dashboard at /queues. If unset, dashboard is not mounted. |
BULL_BOARD_PASSWORD |
— | Password for the Bull Board queue dashboard. Required alongside BULL_BOARD_USER. |
SMS
Pure BYOK (see SMS Module above) — no platform-default credentials for any SMS vendor, so there’s nothing here for
Twilio at all (its accountSid/authToken/fromNumber only ever exist as a tenant’s own encrypted BYOK config).
Termii’s API host is the one exception: infrastructure, not a secret, so it stays env-driven.
| Variable | Default | Description |
|---|---|---|
TERMII_BASE_URL |
https://api.ng.termii.com |
Termii API base URL — same for every tenant’s Termii account, BYOK or not |
YouTube Live Detection (optional, platform-wide only)
Channel id and Data API key are not set here — they’re per-tenant, via PUT /v1/youtube-integration (see
“YouTube Live Detection” above), with no platform-wide fallback for the key. Both below are platform-wide and both
optional — leave unset to skip WebSub subscription entirely and rely on the Sermon Module’s manual “Announce Live”
trigger instead.
| Variable | Default | Description |
|---|---|---|
YOUTUBE_WEBSUB_CALLBACK_URL |
— (optional) | Publicly reachable URL for GET/POST integrations/youtube/callback (must be internet-facing for Google’s hub to reach it) — one physical endpoint shared by every tenant |
YOUTUBE_WEBSUB_SECRET |
— (optional) | Shared HMAC secret sent as hub.secret on subscribe; the hub signs every notification with it (X-Hub-Signature), which the callback verifies. Required alongside the callback URL — without it, subscribe() never registers a live subscription and the callback rejects everything it receives. |
PUBSUBHUBBUB_URL |
https://pubsubhubbub.appspot.com/subscribe |
Google’s PubSubHubbub hub endpoint YoutubeSubscriptionService posts subscribe/unsubscribe requests to |
Pages: Gallery Folder Sync (optional, platform-wide)
Unlike YouTube Live Detection above, this is not per-tenant — one read-only Drive API v3 key, shared by
every tenant’s Gallery sections, since listing files in a public folder needs no tenant-specific
authorization. Leave unset to skip folder sync entirely; a GALLERY section with syncFolderUrl set just
falls back to its manually-saved images, same as before this existed.
| Variable | Default | Description |
|---|---|---|
GOOGLE_DRIVE_API_KEY |
— (optional) | A Google Cloud API key with the Drive API enabled, used only for a read-only files.list call against whatever public folder an admin points a Gallery section at (GalleryFolderSyncService) — no OAuth, no connected account |
Billing: Paystack / Flutterwave (optional, platform-wide)
All optional — a provider whose secret key isn’t set simply can’t be selected as ?provider= on a checkout call
(PaymentProviderRegistryService throws a clean 400, not a crash). Platform-wide, not tenant BYOK — see “Billing
& Checkout” above for why.
| Variable | Default | Description |
|---|---|---|
PAYSTACK_SECRET_KEY |
— (optional) | Paystack secret key, used both for API calls (Authorization: Bearer) and to compute the HMAC-SHA512 webhook signature |
PAYSTACK_BASE_URL |
https://api.paystack.co |
Paystack API base URL |
FLUTTERWAVE_SECRET_KEY |
— (optional) | Flutterwave secret key, used for API calls |
FLUTTERWAVE_SECRET_HASH |
— (optional) | Shared secret configured in the Flutterwave dashboard’s webhook settings — compared verbatim against the verif-hash header, not an HMAC key |
FLUTTERWAVE_BASE_URL |
https://api.flutterwave.com/v3 |
Flutterwave API base URL |
MONNIFY_API_KEY |
— (optional) | Monnify API key for platform billing; MK_TEST_… keys use the sandbox |
MONNIFY_SECRET_KEY |
— (optional) | Monnify secret key — sign-in and webhook signature (monnify-signature, HMAC-SHA512) |
MONNIFY_CONTRACT_CODE |
— (optional) | Monnify contract code the platform’s charges settle under |
DEFAULT_PAYMENT_PROVIDER |
paystack |
Which provider a checkout call uses when it doesn’t specify ?provider= explicitly (paystack, flutterwave, kora, monnify) |
SUBSCRIPTION_PERIOD_DAYS |
30 |
Renewal period CheckoutService.applyChargeSucceeded() extends currentPeriodEnd by per successful charge, for a billingInterval: 'monthly' plan |
ANNUAL_SUBSCRIPTION_PERIOD_DAYS |
365 |
Same, for a billingInterval: 'annual' plan |
GRACE_PERIOD_DAYS |
7 |
How long SubscriptionLapseScheduler keeps a PAST_DUE subscription’s features before downgrading to Free |
11. Enum Reference
MemberRoleEnum
MEMBER · WORKER
Admin portal access is not a member role — it is managed via the Admin entity and AdminRole.
AdminPermission
Granular permissions assigned to AdminRole records:
members:read · members:write · events:read · events:write · venues:read · venues:write ·
departments:read · departments:write · attendance:read · attendance:write · leave:read · leave:write · classes:read ·
classes:write · announcements:read · announcements:write · dashboard:read ·
sunday_school:read · sunday_school:write · children_church:read · children_church:write · admin:read ·
admin:write · audit:read · finance:read · finance:write · follow_up:read · follow_up:write ·
service_programme:read · service_programme:write · headcount:read · headcount:write ·
prayer:read · prayer:write · sms:read · sms:send · sermon:read · sermon:write
GET /enums returns these as both a flat adminPermissions list (value + label) and a grouped adminPermissionGroups list (group name + permissions with value, label, and description) — use the grouped form to render the permission assignment UI. sms:read/sms:send are grouped under “SMS Messaging”.
MemberImportJobStatus
READY_FOR_REVIEW · COMMITTED
MemberImportRowStatus
PENDING · CREATED · FAILED
MemberStatusEnum / WorkerStatusEnum
ACTIVE · INACTIVE
GenderEnum
MALE · FEMALE
MaritalStatusEnum
SINGLE · MARRIED · DIVORCED · WIDOWED
AttendanceStatusEnum
PRESENT · LATE (workers only) · ABSENT · ON_LEAVE (workers only) · ATTENDED_ONLINE
LeaveStatusEnum
PENDING · APPROVED · REJECTED
ChurchClassTypeEnum
BELIEVERS · BAPTISMAL · WORKERS_IN_TRAINING · BIBLE_COLLEGE · SCHOOL_OF_DISCIPLESHIP
Legacy — not used at runtime. Class types are now admin-creatable via the ClassType entity (see Data Models above); this enum only documents the 5 values the AddClassTypesTable migration seeded as rows, for reference when reading that migration.
EnrollmentStatusEnum
IN_PROGRESS · COMPLETED · CANCELLED
AnnouncementAudienceEnum
ALL · WORKERS_ONLY · MEMBERS_ONLY · DEPARTMENT · INDIVIDUAL · GROUP · CLASS
NoteTypeEnum (path param values)
child_naming · child_dedication · marriage · baptism
EventRecurrencePatternEnum
daily · weekly · monthly
OrderBy (Events)
eventDate · createdAt · updatedAt
DepartmentCapability
A fixed, validated enum — not free text. Department.capabilities: DepartmentCapability[];
CreateDepartmentDto/UpdateDepartmentDto validate every entry with @IsEnum(DepartmentCapability, { each: true }).
A department can hold any combination of these; each is named after the action it unlocks rather than after a
department, so it stays meaningful regardless of what a given church calls the department that holds it.
MANAGE_SUNDAY_SCHOOL · MANAGE_CHILDREN_CHURCH · MANAGE_PRAYER_REQUESTS · MANAGE_EVANGELISM_CONVERTS · MANAGE_FOLLOW_UP · FRONT_DESK_OPERATIONS
SundaySchoolAttendanceStatus
PRESENT · ABSENT · EXCUSED
MeetingDayEnum (Sunday School)
SUNDAY · MONDAY · TUESDAY · WEDNESDAY · THURSDAY · FRIDAY · SATURDAY
GuardianRelationshipEnum
MOTHER · FATHER · GRANDPARENT · SIBLING · UNCLE · AUNT · FAMILY_FRIEND · OTHER
ChildCheckInStatusEnum
CHECKED_IN · CHECKED_OUT · FLAGGED
ReminderIntervalPresetEnum
15m (15 min) · 30m (30 min) · 1h (1 hour) · 3h (3 hours) · 24h (24 hours) · 48h (48 hours)
TitheBatchStatus
PENDING · PROCESSING · COMPLETED · FAILED
TitheUnmatchedStatus
PENDING · MATCHED · DISMISSED
TitheDisputeStatus
PENDING · CONFIRMED_VALID · REJECTED
FinanceRequestStatus
PENDING · APPROVED · REJECTED
FirstTimerSourceEnum
WALK_IN · ONLINE · REFERRAL
FollowUpTaskTypeEnum
FIRST_TIMER · ONLINE_NO_RESPONSE · MANUAL
FollowUpTaskStatusEnum
PENDING · IN_PROGRESS · COMPLETED · UNREACHABLE
FollowUpOutcomeEnum
JOINED · DECLINED · NO_ANSWER · PRAYED_WITH
ServiceProgrammeStatusEnum
DRAFT · LIVE · COMPLETED
ServiceSlotTypeEnum
SPEAKER · WORSHIP · PRAYER · OFFERING · ANNOUNCEMENT · BREAK
ServiceSessionStatusEnum
LIVE · COMPLETED
ServiceSessionSlotStatusEnum
PENDING · IN_PROGRESS · COMPLETED · SKIPPED
ServicePauseReasonEnum
TECHNICAL_ISSUE · ANNOUNCEMENT · BREAK_INTERVAL · UNPLANNED_DELAY · OTHER
ServiceActionRoleEnum
ADMIN · WORKER · PUBLIC_LINK (action performed via the public Programme Manager share link, no authenticated member)
IncidentStatusEnum
OPEN · IN_PROGRESS · RESOLVED
AssetStatusEnum
ACTIVE · INACTIVE · UNDER_MAINTENANCE · DECOMMISSIONED
MaintenanceFrequencyUnitEnum
DAYS · WEEKS · MONTHS
MaintenanceRecordTypeEnum
SCHEDULED · UNPLANNED
MaintenanceCompletionStatusEnum
IN_PROGRESS · COMPLETED
AssetConditionEnum
GOOD · FAIR · POOR
PrayerDayMode
PHYSICAL · VIRTUAL
PrayerRuleType
ROLE_FREQUENCY · MIN_LEADERS_PER_MEETING · MAX_PER_MEETING
PrayerAssignmentType
FIXED · SELF_SELECTED · AUTO_ASSIGNED
PrayerRosterStatus
SCHEDULED · RESCHEDULED
PrayerMeetingStatus
SCHEDULED · COMPLETED · CANCELLED
PrayerWindowStatus
PENDING · OPEN · CLOSED
RentalMemberCategory
PUBLIC · MEMBER · WORKER · LEADER
Determines which pricing tier is applied. Resolved at booking time: LEADER if a DepartmentLead record exists for the member, WORKER if role = WORKER, otherwise MEMBER.
RentalDiscountType
PERCENTAGE · FLAT
RentalDiscountSource
NONE · TIER · OVERRIDE
Stored as a snapshot on the booking to record how the discount was determined.
RentalBookingStatus
PENDING · CONFIRMED · IN_PROGRESS · COMPLETED · CANCELLED · REJECTED
Transitions: PENDING → CONFIRMED (admin) or CANCELLED/REJECTED; CONFIRMED → IN_PROGRESS (scheduler); IN_PROGRESS → COMPLETED (scheduler).
RentalPaymentType
SERVICE_FEE · CAUTION
RentalPaymentStatus
PENDING · PAID · REFUNDED (REFUNDED only valid for CAUTION payments)
LivePlatformEnum
YOUTUBE · MIXLR
Used by the Sermon Module’s manual “Announce Live” trigger to pick the default announcement title.
GameStatusEnum
DRAFT · LIVE_SESSION_ACTIVE · ARCHIVED (not yet reachable via any endpoint)
Informational, not a gate — a DRAFT game can always be edited even if LIVE_SESSION_ACTIVE from a past session
that hasn’t been ended yet.
GameSessionStatusEnum
SCHEDULED (not yet reachable — sessions start directly into LIVE) · LIVE · ENDED
ReminderSettingKey
pledge_reminder · budget_alert · follow_up_stale · asset_maintenance · asset_warranty · vehicle_expiry ·
assignment_due · class_session
See “Reminder Settings Module” above — keys a tenant’s per-category reminder timing/enabled settings
(GET/PATCH /admin/reminder-settings). Distinct from EmailCategory (a separate, coarser enum — see below).
EmailCategory
ATTENDANCE_CHECKIN · BIRTHDAY · EVENT_REMINDER · PRAYER_REMINDER · FOLLOW_UP · ASSET_ALERTS ·
GIVING_RECEIPT · FINANCE_ALERTS · SESSION_REPORT · INCIDENT_REPORT · CHILDREN_CHURCH · LOGIN_ALERT ·
SERVICE_PROGRAMME_ASSIGNMENT · PASTOR_FEEDBACK · MEMBERSHIP_ANNIVERSARY · ASSIGNMENT_REMINDER ·
CLASS_SESSION_REMINDER
See “Email Category Settings Module” above — every category-tagged email checks a global env-flag gate, then a
per-tenant EmailCategorySettingsService gate, before EmailQueueService.queueEmail enqueues the job
(GET/PATCH /admin/email-category-settings). Emails sent with no category (OTP, password reset, account-locked —
security-critical auth flows) always send regardless, by design.