Health ↗

Discuva — Technical Documentation

Table of Contents

  1. System Overview
  2. Architecture
  3. Data Models
  4. Authentication & Authorization
  5. Module Reference
  6. API Endpoints Quick Reference
  7. Check-In Flow
  8. Automated Absence Marking
  9. Role & Permission Matrix
  10. Environment Variables
  11. 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:


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


3. Data Models

Member

The universal identity for every person in the system.

Field Type Notes
id UUID PK
firstname, lastname string
email 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
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
pastor Pastor | null OneToOne, null unless the member carries a pastoral designation — see Pastor 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/pastorType disambiguation for same-named celebrants (see Birthday Module).

WorkerProfile

Created when a member is promoted to WORKER. Never deleted by any revocation pathrevokeWorker 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. 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.

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:

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.

Pastor

A pastoral designation on a member, independent of WorkerProfile/Department — a pastor 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
type PastorTypeEnum LEAD | PARISH | ASSOCIATE

Managed via POST/PATCH/DELETE /members/:id/pastor (see Member Module). Surfaced on MemberDto as pastorType: PastorTypeEnum | null, computed from the pastor relation.

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.
endDate Date (date only) Derived: the latest serviceSlots[].endTime (UTC date). Recomputed alongside eventDate.
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.
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
serviceSlots ServiceSlot[] OneToMany — at least one slot is required at creation
attendances Attendance[] OneToMany

Venue

A named, reusable physical location. Referenced by EventConfig.defaultVenue and optionally overridden per slot via ServiceSlot.venueOverride.

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 ManyToOne, nullable — overrides config.defaultVenue for this slot
*Override columns int Per-slot overrides that take priority over EventConfig

Override columns: workerCheckinStartOverride, workerLateOverride, memberCheckinStartOverride, checkinStopOverride, allowedDistanceOverride

effectiveVenue: computed as slot.venueOverride ?? slot.config.defaultVenue. Throws 400 if neither is set.

EventConfig

A reusable timing template assigned to service slots. Venue is now a first-class relation rather than raw lat/lon.

Field Type Description
name string Unique
defaultVenue Venue ManyToOne, NOT NULL — the venue used by all slots referencing this config unless overridden
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
allowedDistanceInMeters int Max distance from effectiveVenue for location validation

Constraint: workerLateOffset > workerCheckinStartOffset and checkinStopOffset > workerLateOffset

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:

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
respondedByPastor ManyToOne → Pastor, nullable (onDelete: SET NULL)
respondedByPastorName 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 (pastor response): the caller must have a Pastor record (pastorRepo.exists({ member: { id } }), any PastorTypeEnum). Available via both the admin portal (an Admin account whose linked Member has a Pastor record) and the mobile app (any member with a Pastor record).

PrayerRequest

A private prayer request submitted by any member/worker — visible only to the submitter, Prayer department workers, and pastors.

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)
facilitator ManyToOne → Member (nullable)
startDate / endDate date strings

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.

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.”

ClassEnrollment

Field Notes
member ManyToOne → Member
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

Unique constraint: (member, churchClass)

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
department ManyToOne → Department (required when audience=DEPARTMENT)
targetMember ManyToOne → Member, nullable (required when audience=INDIVIDUAL)
group ManyToOne → Group, nullable (required when audience=GROUP)
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

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:

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.)
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.

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
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.

Indexes: recipient, status, createdAt.

Audit Actions: ADMIN_CREATED · MEMBER_SIGNED_UP · MEMBER_LOGIN · MEMBER_LOGOUT · ADMIN_LOGIN · 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 · 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_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

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

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)

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
email 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

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.

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
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

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

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
email string | null Optional
source FirstTimerSourceEnum WALK_IN | ONLINE | REFERRAL
wantsToJoinChurch boolean Default false
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

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 control the session
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:

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

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
  1. Login → receives access_token + requires_password_change (and refresh_token in body for mobile only). A surface-scoped session row is created (or updated) with a hashed refresh token. If requires_password_change is true, the client must redirect the user to POST /auth/change-password before allowing any other action.
  2. Access token expires → call POST /auth/refresh. Admin web clients rely on the httpOnly cookie (sent automatically); mobile clients send the refresh token in the Authorization: Bearer header. The refresh token carries aud and renews the same-surface session.
  3. 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:

Absolute Session Lifetime

Each session row in member_sessions is upserted per member + surfaceupdateLogin() 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:

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.

Device Lock (Mobile App)

Only one device may be logged into the mobile app per member account. This prevents proxy check-ins.

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.

  1. POST /auth/device-reset/request — accepts { email, newDeviceId }. Rate-limited per email (default: 3 attempts per 24-hour window, configurable via DEVICE_RESET_MAX_ATTEMPTS and DEVICE_RESET_WINDOW_SECONDS). Generates a 6-digit OTP, stores an Argon2 hash and the newDeviceId in device_reset_otps, and emails the code. Always returns the same success message to avoid leaking account existence.
    • Security note: newDeviceId is 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.
  2. POST /auth/device-reset/verify — accepts { email, otp }. Verifies the OTP, checks expiry, marks the record as used, updates member.deviceId to the newDeviceId stored on the OTP record, invalidates all active sessions, and sends a confirmation email. On success the member must log in fresh from the new device.
    • 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).

Forgot Password / OTP Reset Flow

  1. POST /auth/forgot-password — rate-limited (default: 3 attempts per hour, configurable via env). Generates a 6-digit OTP, stores an Argon2 hash in password_reset_otps, and emails the code. Always returns the same success message to avoid leaking account existence.
  2. POST /auth/reset-password — rate-limited (5 attempts/min, same as forgot-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.

This endpoint is also how a brand-new tenant’s first admin sets their initial password — see “Tenant Welcome / Set Password Flow” below.

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.

  1. POST /auth/email-change/request — accepts { newEmail }. Returns 409 Conflict if newEmail is already used by another member. Rate-limited the same way as forgot-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 target newEmail in email_change_otps (mirrors DeviceResetOtp’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.
  2. 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 that newEmail is still unclaimed (409 on a race), marks the record used, updates member.email to the stored newEmail, and emails a confirmation to the new address.

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:

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 | PROVISIONING | ACTIVE | FAILED) is orthogonal to isActiveisActive 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.

Self-serve signup flow (async):

  1. TenantProvisioningService.ensurePendingTenant(subdomain, churchName, parentTenantId?) — the find-or-create part of the old provision(), now its own method — creates the Tenant row (onboardingStatus: PENDING) or returns the existing one if resuming. Callable standalone specifically so SignupController gets a real tenant.id back before handing off to the queue.
  2. recordEvent(tenantId, 'SIGNUP_INITIATED', SELF_SERVE) — see the audit trail note below.
  3. A TENANT_PROVISIONING_JOB is enqueued (ProvisionTenantParams + tenantId + actorType/actorId + branchInviteToken), attempts: 3, backoff: { type: 'exponential', delay: 5000 } (this codebase’s standard retry convention, e.g. TitheProcessor).
  4. TenantProvisioningProcessor sets onboardingStatus = PROVISIONING, records PROVISIONING_STARTED, calls provision() (unchanged), then on success sets onboardingStatus = ACTIVE (provision() already flips isActive), records PROVISIONING_COMPLETED, and consumes the branch invite (BranchInviteService.markAccepted, moved here from SignupController since the controller no longer awaits completion). On permanent failure (all 3 attempts exhausted, via @OnQueueFailed()) sets onboardingStatus = FAILED and records PROVISIONING_FAILED with the error message in metadata.
  5. GET /signup/:tenantId/status (@Public()) — polled by the caller until status is ACTIVE (or FAILED). Unauthenticated by design, same reasoning as POST /signup itself; excluded from TenantMiddleware alongside it in TenantModule (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’s path-to-regexp version throws PathError on boot for that shape; only a suffix wildcard like v1/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 | 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 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.

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:

Sunday School access — a request passes if any of the following is true:

  1. Caller is a WORKER whose primary or secondary department has the MANAGE_SUNDAY_SCHOOL capability.
  2. 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:

  1. Caller is a WORKER whose primary or secondary department has the MANAGE_CHILDREN_CHURCH capability.

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.comchurch-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:

  1. A verified JWT tenant claim. Every access/refresh token, both surfaces, embeds tenantId/schemaName in the payload at sign time (AuthService.generateTokens(), reading cls.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). TenantMiddleware checks the Authorization: Bearer header first — trying the access secret, then REFRESH_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-existing RefreshJwtStrategy design, not something added for this) — then the refresh_token httpOnly cookie (verified with REFRESH_JWT_SECRET) as a second fallback. Covers every authenticated request, including a bare /v1/auth/refresh call 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 arbitrary tenantId into a token it can’t forge the signature for.
  2. X-Tenant-Subdomain header. discuva-admin sends it on every pre-auth request where no token exists yet: POST /v1/auth/admin-login (needs to know which tenant’s Member/Admin tables to check credentials against before it can issue anything), and POST /v1/auth/forgot-password/reset-password (same reasoning — PasswordResetOtp is tenant-schema-scoped too, and both the “Forgot password” flow on the login screen and the first-time /set-password flow reached from a welcome email are equally pre-auth). discuva-member sends it on every request to api.discuva.org (derived from its own Host header via getCurrentTenantSubdomain(), utils/tenant/api-base-url.ts), not just pre-auth ones — harmless to include always, and it’s what app/manifest.ts’s server-side, pre-auth GET /tenant/info call 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.

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:

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().

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/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.

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 (UpdateMyProfileDto, all fields optional). Deliberately excludes email (handled by the OTP-gated email-change flow — see Self-Service Email Change Flow), and the admin-only church-record fields dateJoinedChurch, yearBornAgain, yearBaptized, baptizedWithHolyGhost.

Pastor designation: three AdminGuard + MEMBERS_WRITE routes manage the optional Pastor relation on a member (same permission as promote-to-worker — no separate permission was introduced):

pastorType: PastorTypeEnum | null is surfaced on MemberDto (GET /auth/me, GET /members/:id, GET /members, GET /members/workers), computed from the pastor relation.

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):

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):

Admin user routes (/admin/users):

Security notes:

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:

  1. A Member with role = MEMBER and changedPassword = false
  2. A SuperAdmin AdminRole carrying all permissions
  3. An Admin record linking the two

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)

Event Module

Manages events and service slots. Events can be single or recurring (daily/weekly/monthly). At least one serviceSlot is required at creation — each slot carries an optional configId pointing to an EventConfig. For recurring events the same slot template (including configId) is stamped onto every generated occurrence; updating the config later propagates to all check-ins that reference it.

CreateEventDto takes no eventDate/endDate fields — Event.eventDate/endDate are always derived from the supplied serviceSlots (eventDate = earliest startTime, endDate = latest endTime, both UTC-date-truncated so the result doesn’t depend on server timezone). 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.

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): since the event’s date range is entirely derived from its slots’ times (no manual override, per above), the create/edit form’s SlotRow sets min on the datetime-local inputs (a slot’s End Time can’t be earlier than its own Start Time; each slot after the first has its Start Time’s min set to the previous slot’s End Time, since slots run in sequence) — but min on type="datetime-local" only reliably restricts the browser’s calendar date view; the time-of-day spinner on an already-valid date isn’t blocked interactively in Chrome/most browsers, only flagged :invalid on blur/submit, which read as “not working” for the time portion. updateSlot() therefore also clamps values in JS the instant they change: a slot’s End Time snaps forward to match its Start Time if set earlier, a slot’s Start Time snaps forward to the previous slot’s End Time if set earlier (pulling its own End Time along if that would now precede it), and moving a slot’s End Time later pulls the next slot’s Start Time forward with it if it would otherwise fall behind. min is kept alongside this for the calendar-level hint; the JS clamp is what actually prevents an invalid time-of-day from sticking. Neither replaces backend validation, which still governs what’s actually accepted on submit.

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.

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

Attendance Module

Check-in window logic:

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.”

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.

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:

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

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:

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/writepastor_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 (OPENPRAYED_FORANSWERED), unrelated to any meeting.

Visibility:

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):

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/pastors 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, assertIsPrayerTeamOrPastor 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:

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-candidatePOST 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) — see the ClassEnrollment entity section above.

Study material (ChurchClass.documentUrl): optional, admin-set link to the class’s syllabus/manual (Google Drive, PDF link, etc.) — validated as a URL (@IsUrl()) at the DTO level, but the URL itself can come from three places: a plain external link typed in directly, a fresh upload (POST classes/materials/upload, multipart, field name file), or reusing a URL already in use by another class (GET classes/materials/library — distinct documentUrls across all classes, each with the list of class names currently using it, so the admin doesn’t re-upload the same manual per class type). Upload accepts PDF, Word, PowerPoint, or image mimetypes; size is capped by MAX_CLASS_MATERIAL_UPLOAD_BYTES (env var, 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). Uploaded files land in Cloudinary’s class-materials folder. Whichever source produced the URL, it’s set the same way afterward — documentUrl on POST/PATCH classes.

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.

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 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/materials/upload AdminGuard (CLASSES_WRITE) Multipart, field file{ url }
GET /classes/materials/library AdminGuard (CLASSES_READ) { documentUrl, usedByClassNames }[] — for the “reuse a previous upload” picker

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.

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:

Audience types: ALL | WORKERS_ONLY | MEMBERS_ONLY | DEPARTMENT | INDIVIDUAL | GROUP
When audience = DEPARTMENT, departmentId is required. When audience = INDIVIDUAL, targetMemberId (UUID) is required. When audience = GROUP, groupId (UUID) is required.

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 (GROUPGroupService.getMemberIdsForGroup; 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. On create, an SMS is sent (awaited, not fire-and-forget, since it’s a paid external call whose failure must be caught and logged synchronously) whenever sendViaSms is set. 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.

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:

resolvePhoneNumbers takes a plain { audience, departmentId?, targetMemberId?, groupId? } 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: SendSmsBroadcastDtoaudience (required) + the matching departmentId/targetMemberId/groupId for DEPARTMENT/INDIVIDUAL/GROUP 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. Logs SMS_BROADCAST_SENT to the audit log (metadata: { audience, count }).

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.

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:

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? }[] }; returns {added, skipped} — duplicate 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}
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}

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 — termiiTermiiSmsProvider, twilioTwilioSmsProvider — 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:

Message history (TermiiSmsProvider.getMessageHistory): calls Termii’s GET /api/sms/inbox?api_key=... (undocumented pagination or date-filter params — it’s a flat array of every message on the account) 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?). A non-array response body is treated as empty rather than thrown. 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.

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 Live passthrough to the provider’s message history — SmsLogEntry[], not paginated or filtered server-side; the frontend paginates/filters the returned array client-side

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:

  1. 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 from discuva-admin’s page with no explanation, even though its encrypted credentials are still saved).
  2. SmsCredentialResolverService/EmailCredentialResolverService now also require provider.isActive = true in the same query that already checks config.isActive = true and provider.channel. A deactivated provider genuinely stops resolving for every tenant using it, not just new ones.
  3. 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 own TenantCommunicationProviderConfig row is never touched by any of this — same “don’t retroactively delete something already configured” posture suspendTenant uses 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. Which provider actually handled a given send is carried back via Bull’s job-return-value convention (job.returnvalue) so onCompleted logs the real provider used to EmailLog.provider, not just the platform default — that can differ per send once BYOK is in play.

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/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.

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, mirroring MemberController’s POST members/me/photo exactly (multer FileInterceptor, 3MB limit, image-mimetype-only filter, CloudinaryService.uploadBuffer into the church-logos folder). 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 (24 keys today, e.g. login-backdrop, home-door-welcome, giving-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.

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.

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. Paystack and Flutterwave both lazily create (and persist onto Plan.billingProviderPriceId) a matching provider-side plan/payment-plan object the first time a planId is checked out against, rather than requiring one to be pre-created out of band.

Kora has no verified recurring-subscription API — a real capability gap, documented rather than papered over. Unlike Paystack (plan + Initialize Transaction) and Flutterwave (payment-plans + payment_plan), Korapay has no confirmed subscription/plan product; KoraPaymentProvider.createSubscriptionCheckout is a single charge for the plan’s price (same mechanism as createOneOffCheckout), not an auto-renewing subscription. Concretely: a tenant on Paystack/Flutterwave may be silently re-charged by the provider’s own recurring engine when their period ends (see SubscriptionLapseScheduler below); a tenant on 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. KoraPaymentProvider.cancelSubscription is correspondingly a documented no-op (nothing server-side to cancel), and 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). Don’t set DEFAULT_PAYMENT_PROVIDER=kora without accounting for this.

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 30-day period — see SUBSCRIPTION_PERIOD_DAYS — on Subscription, 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. 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: this is only fully accurate once Subscription.billingProviderSubscriptionId capture is wired up (still deferred, pending live sandbox testing) — until then, a tenant whose provider auto-renewal webhook this codebase doesn’t yet recognize will also pass through PAST_DUE and eventually lapse here, even if they’re genuinely still paying. “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.

Routes (AdminGuard, tenant-scoped, unless noted):

Method Path Permission Description
GET /billing/summary BILLING_READ { planId, planName, subscriptionStatus, currentPeriodEnd, cancelAtPeriodEnd, sponsoredByParent }
GET /billing/plans BILLING_READ Full plan catalog ([{ id, name, priceCents, currency, features }]), ordered by price ascending — the only tenant-accessible plan list; GET /platform/plans is platform-admin-only
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.

Env vars: PAYSTACK_SECRET_KEY, PAYSTACK_BASE_URL, FLUTTERWAVE_SECRET_KEY, FLUTTERWAVE_SECRET_HASH, FLUTTERWAVE_BASE_URL, DEFAULT_PAYMENT_PROVIDER, SUBSCRIPTION_PERIOD_DAYS (default 30, CheckoutService’s flat renewal period), GRACE_PERIOD_DAYS (default 7, SubscriptionLapseScheduler’s PAST_DUE window before downgrading to Free) — see Environment Variables.

Not built yet: automatic capture of Subscription.billingProviderSubscriptionId from a provider’s own subscription-lifecycle webhook (see the dunning limitation note above — this is the same underlying gap); true recurring-billing reconciliation driven by the provider’s own renewal events rather than the flat 30-day period. 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 numeric cap: any route decorated @RequiresPlan(PlanFeature.X) first checks Plan.features membership (unchanged boolean gate, cached under plan-features:${tenantId} for 300s) and throws 403 { code: 'PLAN_UPGRADE_REQUIRED' } if the feature isn’t included. If the feature is included and the plan additionally has a numeric limit configured for it (Plan.featureLimits, a jsonb map of PlanFeature → max lifetime uses, admin-editable via PATCH /platform/plans/:id — absent key means unlimited), the guard atomically checks-and-increments a per-tenant lifetime counter (FeatureUsageService.tryConsume, backed by public.feature_usages, one row per (tenantId, feature), a single conditional INSERT ... ON CONFLICT ... WHERE count < limit so concurrent requests can’t both slip through) and throws the same PLAN_UPGRADE_REQUIRED 403 once the cap is hit. The slot is consumed before the route handler runs (not after it succeeds) — the simplest mechanism that works for every @RequiresPlan route with zero per-feature integration code, at the accepted cost of occasionally spending a use on a request that later fails for an unrelated reason (e.g. a validation error inside the handler). 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. GET /platform/plans / POST /platform/plans / PATCH /platform/plans/:id all accept featureLimits alongside the existing features array; PlatformPlanService deep-validates it (each key must be a real PlanFeature, each value a positive integer).

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/Flutterwave 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 a provider 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.mrrCents() 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.

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 computationcomputeAndUpsertOne 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 all reuse the same builder). A form has a visibility of MEMBERS or PUBLIC, 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 — while MEMBERS forms require an authenticated member/worker token.

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.

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.

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.

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, and DROPDOWN/CHECKBOX values must be one of the field’s configured options.

Method Route Auth Notes
POST /forms AdminGuard (FORMS_WRITE) Create a form with its fields in one call
GET /forms AdminGuard (FORMS_READ) List all forms — unpaginated, same policy as departments/event-configs
GET /forms/:id AdminGuard (FORMS_READ) Get one form with fields
PATCH /forms/:id AdminGuard (FORMS_WRITE) Update form + diff-sync fields (see above)
DELETE /forms/:id AdminGuard (FORMS_WRITE) Cascades fields + submissions
GET /forms/:id/submissions AdminGuard (FORMS_READ) Paginated (?page=&limit=) — this list is attendance-scale, unlike the forms list itself
GET /forms/:id/submissions/export AdminGuard (FORMS_READ) CSV, one column per field (ordered), Submitted By shows the member’s name or “Public”
GET /forms/:id/analytics AdminGuard (FORMS_READ) At-a-glance summary across all submissions, computed per field type (see below)
GET /forms/member JwtAuthGuard Forms visible to the caller (isActive, MEMBERS or PUBLIC) — optional ?eventId= filter
GET /forms/member/:id JwtAuthGuard Form fields + suggestedValues auto-filled from the caller’s own profile
POST /forms/member/:id/submit JwtAuthGuard memberId comes from the token, never the body
GET /forms/public/:id Public, 404 unless isActive && visibility === PUBLIC No tenant subdomain restriction beyond the usual Host-header resolution
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

forms is a toggleable module (KNOWN_MODULES, ModuleEnabledGuard) like most feature modules in this app — disabling it 403s all three controllers, including the public one.

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}; 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.

Social Media Module (src/social-media/)

Central, tenant-scoped connector framework for cross-posting to a church’s social accounts from one compose box — currently the architecture only: no platform has live OAuth wired in yet, deliberately. Every account starts (and stays) isConnected: false, and every publish attempt against it fails honestly rather than pretending to succeed — see “Why no live platforms yet” below.

Entities:

The publisher extension point (publisher/social-platform-publisher.interface.ts): SocialPlatformPublisher is a one-method interface (publish(account, post): Promise<{success, error?, externalPostId?}>). Every platform resolves to NotConnectedPublisher via SocialPublisherRegistry today, which always returns {success: false, error: "<PLATFORM> isn't connected yet — ..."}. Wiring in a real platform later (Meta Graph API, X API, etc.) means implementing this interface once and swapping that platform’s entry in the registry’s map — SocialPostService and the controller never change.

Why no live platforms yet: each platform is a separate OAuth app registration, a different API surface, and in X’s case a paid API tier — a real scope decision, not a technical default. This session shipped the full compose → target-selection → publish-attempt → per-target-result UX so it’s ready to point at real credentials the moment that’s decided, without ever telling an admin a post went out when it didn’t.

Publish semantics (SocialPostService.publish): every target is attempted independently — one platform 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.

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
GET /social-media/accounts AdminGuard (SOCIAL_MEDIA_READ) List all registered accounts
DELETE /social-media/accounts/:id AdminGuard (SOCIAL_MEDIA_WRITE) Remove an account
POST /social-media/posts AdminGuard (SOCIAL_MEDIA_WRITE) {content, imageUrl?, targetAccountIds: string[]} — creates a DRAFT with one PENDING target per account
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 and each target’s account
POST /social-media/posts/:id/publish AdminGuard (SOCIAL_MEDIA_WRITE) Attempts every target; see publish semantics above
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).

Utility Module

Shared infrastructure used across the entire application.

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.

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:

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.

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 hostingextractSubdomain 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

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.

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):

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, pastorType, 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), pastorType (from the pastor relation), 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 }).

Notes Module

Pastoral records of significant events (child naming, dedication, marriage, baptism — “rites of passage”). Admin-only write/read surface. Stored as typed JSON detail objects.

Note types: child_naming, child_dedication, marriage, baptism

Optional member link: every note type accepts an optional memberId on create/update, stored as a real FK (Note.member, nullable, SET NULL) separate from the JSON details blob — not part of any single type’s shape, since linking to a member is a cross-cutting concern, not detail data. Most naming/dedication records are for someone not yet in the system (e.g. a newborn) so this stays optional; when set, the record becomes visible on that member’s own profile via GET members/me/milestones (JwtAuthGuard, own data only, no admin permission needed). Passing memberId: null on update explicitly unlinks a note.

Routes prefix: /notes, /notes-analytics, /members/me/milestones

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:

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.

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:

  1. Finance admin selects a TitheAccount and uploads .xlsx via POST /admin/tithes/upload (multipart, field name file; body field titheAccountId).
  2. Service validates that the account exists and is active, validates required columns (Email, Amount, Payment Date), and returns 400 immediately for invalid input.
  3. A TitheUploadBatch record is created (linked to the account, with parsed rows stored as JSONB for safe requeue) and a Bull job (tithe queue, process-batch job) is dispatched with attempts: 3, removeOnFail: false.
  4. The processor runs asynchronously inside a database transaction: matches each row to a member by email (case-insensitive), creates TitheRecord for matches, TitheUnmatchedRecord for no-match rows, and TitheDisputeRecord for 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.

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.

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, Sender Bank, Reference.

Member visibility: Members view their own tithes at GET /tithes/me and request a PDF statement emailed to them at POST /tithes/me/statement/send (TitheService.emailTitheStatement — renamed from /tithes/me/download, which never downloaded anything; it always emailed a PDF). Optional query params fromMonth and toMonth (format YYYY-MM) filter the records included in the statement and display a period range in the PDF (e.g. ?fromMonth=2026-01&toMonth=2026-06). If only one bound is supplied the other is open-ended. Returns { message, recordCount } (200 OK) — previously returned 204 No Content, which meant the frontend’s success message could never actually render since HTTP clients discard the body of a 204 regardless of what the server sends. The email body itself (not just the attached PDF) also states the period in prose (formatStatementPeriod() — “January 2026 – June 2026” / “March 2026 onwards” / “Up to June 2026”) and the record count, falling back to “all N tithe records on file” when no range was requested, so the email is accurate on its own without needing to open the PDF.

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 or declining 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.
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, or external payees. Stored as a separate table to preserve FK integrity and allow multiple associations per transaction.
ExternalPayee Tracks global church remittances, vendors, utilities, contractors, government bodies.
Offering Records Sunday cash + expected transfer amounts. Reconciled separately by finance team. fund_id is 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:

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 four 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):

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 the optional titheAccountId (falls back to CURRENCY_CODE), generates a giving_{uuid} reference, and calls the resolved provider. Saves a PENDING GivingCheckoutSession row before returning { checkoutUrl }.

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:

  1. 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).
  2. GivingCheckoutService.resolveActiveConfig() now joins GivingProvider and requires provider.isActive = true, not just config.isActive. A deactivated provider genuinely stops accepting new checkout initiations. Deliberately not applied to handleWebhook — an in-flight checkout that already charged the member on the provider’s own side must still complete and credit the church’s TitheRecord even 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.
  3. 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 reason communicationProviderCacheKey was — two places already computed the identical string independently) and emails those tenants via TenantBroadcastService.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, titheAccountId?, successUrl, cancelUrl } — returns { checkoutUrl }
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 four 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 four vendors have an equivalent fixed-but-non-secret host worth externalizing).

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):

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:

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
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 Pledge totals for a campaign (campaignId required)
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/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 tithe total, active pledges, last tithe — cross-type giving view
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:

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:

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:


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.

Email notifications:

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).

Admin list filters: GET /admin/finance/requests now accepts additional query params for richer filtering:

Param Type Description
status enum PENDING | APPROVED | REJECTED
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.

Post-event jobs (Bull queue follow-up):

  1. After markAbsentees() completes for an event, a post-event Bull job is dispatched.
  2. PostEventProcessor.handlePostEvent sends thank-you emails to all PRESENT/LATE members if event.thankYouSentAt is null, then sets thankYouSentAt — preventing duplicate sends on re-trigger.
  3. If event.onlineAttendanceEnabled = true: sends online-confirm request emails to ABSENT members, sets event.onlineNotificationSentAt, and schedules a online-window-closed delayed job (ONLINE_CHECKIN_WINDOW_HOURS hours later, default 3).
  4. handleOnlineWindowClosed creates ONLINE_NO_RESPONSE follow-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:

  1. Checks event.onlineAttendanceEnabled = true
  2. Validates that now ≤ onlineNotificationSentAt + ONLINE_CHECKIN_WINDOW_HOURS
  3. Finds the ABSENT record for (member, event) and updates status to ATTENDED_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.

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.

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

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: Convert (converts) — name, phone (nullable), notes (nullable), status (UNSAVED | SAVED | UNDERGOING_DISCIPLESHIP, default UNSAVED), onboardedBy/onboardedByName (who uploaded them, snapshotted), assignedTo (ManyToOne → WorkerProfile, nullable SET NULL — who is currently following up, mirrors FollowUpTask.assignedTo), member/linkedAt (set once the convert becomes an actual Member, mirrors first_timers.converted_member_id/converted_at), 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 the FirstTimerVisit idiom.

Access model:

Follow-up staleness (no cron): GET evangelism/converts/team computes daysSinceLastContact and isOverdue per convert on every read (overdue = no contact in the last 7 days, and not yet linked to a member) — a UI indicator only, not a background job or notification.

Follow-up history: every ConvertFollowUpLog row written by POST evangelism/converts/:id/follow-up is readable back via GET evangelism/converts/:id/follow-up-history (mobile, Evangelism-dept worker) and GET evangelism/converts/admin/:id/follow-up-history (admin, EVANGELISM_READ) — paginated, newest first. Both share ConvertService.getFollowUpHistory().

Routes (mobile, worker/team): POST evangelism/converts, GET evangelism/converts/team?status=&page=&limit=, POST evangelism/converts/:id/follow-up, PATCH evangelism/converts/:id/status, GET evangelism/converts/:id/follow-up-history?page=&limit= Routes (admin portal): GET evangelism/converts/admin?status=&page=&limit=, PATCH evangelism/converts/admin/:id/reassign, PATCH evangelism/converts/admin/:id/link-member, GET evangelism/converts/admin/:id/follow-up-history?page=&limit=

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-liveSERMON_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 (SermonNote entity, same module): a private per-member journal entry on a sermon — sermon (CASCADE), member (CASCADE), note (text), unique on (sermon, member) so a member has exactly one editable note per sermon (upsert, not a multi-entry thread — matches “notes on this sermon” rather than a running journal). No admin-facing surface and no separate permission: GET/PUT/DELETE sermons/:id/note are gated only by JwtAuthGuard and the module’s existing @RequiresModule('sermons') check, since a note is the requesting member’s own data.

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 “use the platform’s YOUTUBE_API_KEY
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, unchanged by the per-tenant redesign): 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. YOUTUBE_API_KEY is a platform-wide fallback only, used when a tenant configured a channel but not their own key. 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.

Tenant self-service routes (AdminGuard, 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:

  1. 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, including hub.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 way FollowUpScheduler is) calls renewAllActive(), which re-subscribes every isActive tenant integration — WebSub leases expire (~5-10 days), so daily renewal keeps every tenant comfortably ahead of expiry regardless of what the hub grants.
  2. GET integrations/youtube/callback handles the hub’s verification handshake — echoes back hub.challenge verbatim (required by the WebSub spec) for subscribe/unsubscribe modes, 404 otherwise.
  3. POST integrations/youtube/callback receives the actual “video published” notification — an Atom XML body containing both a <yt:videoId> and a <yt:channelId>. Before doing anything else, YoutubeWebhookController verifies the X-Hub-Signature header (sha1=<hex>) against an HMAC-SHA1 of the raw body computed with YOUTUBE_WEBSUB_SECRET, using timingSafeEqual — 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.
  4. YoutubeLiveDetectionService takes 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 the TenantYoutubeIntegration owning the notified channelId (isActive: true) — an unrecognized or inactive channel is dropped silently, no error. It then checks that integration’s lastAnnouncedVideoId (the actual idempotency check — the same video can generate multiple WebSub pings across retries/redeliveries). If new, resolves the API key to use (the tenant’s own decrypted key if configured, otherwise the platform’s YOUTUBE_API_KEY) and calls the YouTube Data API (videos.list?part=snippet) to confirm snippet.liveBroadcastContent === 'live' — the WebSub ping alone fires for regular uploads too, not just livestreams — and that snippet.channelId matches 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 owning Tenant, manually enter that tenant’s CLS/transaction context (cls.runWith({tenantId, schemaName}, () => txHost.withTransaction(async () => { SET LOCAL search_path; ... })) — the same pattern PlatformTenantService.impersonateTenant uses; a webhook has no request-scoped TenantMiddleware run to inherit tenant context from, so it has to open one itself), call createSystemAnnouncement() inside it, and persist the video id as the new lastAnnouncedVideoId on the (public-schema) integration row afterward.
  5. 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_API_KEY (optional, platform-wide fallback), 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:

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:


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:

Trigger points:

Event Who is notified
Selection window opened (openSelectionWindow) All active workers
Auto-assign completes (autoAssign) Each newly assigned worker
Manual assignment (manualAssign) The assigned worker or member
Entry removed (removeEntry) The affected worker or member
Entry rescheduled (reschedule) The affected worker or member
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)

Entity: push_subscriptionsid, 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:

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:

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:

Access control:

WebSocket:

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.

Session lifecycle: 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 sets currentQuestionIndex = 0 and stamps currentQuestionStartedAt; nextQuestion advances the index and re-stamps the timestamp (400 if already on the last question — call endSession instead); endSession is idempotent (a second call is a no-op, not an error). hostAdmin is recorded at start — only that admin (or any admin if hostAdmin was somehow cleared) can control the session via nextQuestion/endSession (ForbiddenException otherwise), independent of the general GAMES_WRITE permission check the route itself already enforces.

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), or 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); 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):

Note: games/sessions/:code/state on the participant controller below is @Public(), not JwtAuthGuard-gated.

Routes (participant, JwtAuthGuard + @RequiresModule('games')):

WebSocket:

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, games:write) 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. 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.

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 polls 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.

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_MODERATEservice_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

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).

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.

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 }.
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.
GET /platform/plans List plan tiers.
POST /platform/plans Create a plan tier.
PATCH /platform/plans/:id Edit a plan tier’s price/currency/features/featureLimits (numeric usage cap per PlanFeature — see Billing & Checkout 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.

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:

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 SuperAdmin role holding every PlatformAdminPermission. The AddPlatformAdminRoles migration also seeds this same SuperAdmin role directly 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.

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[], mrrCents } — headline numbers
GET /platform/analytics/growth ?period=&months={ period, signups: [{periodLabel, count}], currentActiveTenants, currentSuspendedTenants }
GET /platform/analytics/revenue ?period=&months={ period, 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/login is accessed as POST /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 (served at /v1/health) — probes DB and Redis; returns 503 with details if either is unreachable. Exempt from rate limiting (@SkipThrottle).
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: booleantrue if the authenticated member has a row in department_leads; pastorType: PastorTypeEnum | 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
PATCH /members/me Any (JwtAuthGuard) Self-service profile edit: firstname, lastname, phoneNumber, gender, birthDay, birthMonth, birthYear, maritalStatus (excludes email and admin-only church-record fields)
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= AdminGuard (MEMBERS_READ) List members — filterable by role; search matches firstname, lastname, email, or phone (case-insensitive)
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
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/pastor AdminGuard (MEMBERS_WRITE) Assign pastoral designation, body { type: PastorTypeEnum }; 409 if already a pastor
PATCH /members/:id/pastor AdminGuard (MEMBERS_WRITE) Change pastor type, body { type: PastorTypeEnum }; 404 if not a pastor
DELETE /members/:id/pastor AdminGuard (MEMBERS_WRITE) Remove pastoral designation; 404 if not a pastor; returns 204
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
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; one record per event per member)
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)
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?). 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)
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
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 JwtAuthGuard (any worker) Upload a convert (only name required)
GET /evangelism/converts/team?status=&page=&limit= JwtAuthGuard (Evangelism-dept worker) Cross-member browse with follow-up staleness fields (mobile)
POST /evangelism/converts/:id/follow-up JwtAuthGuard (Evangelism-dept worker) Log a follow-up contact
PATCH /evangelism/converts/:id/status JwtAuthGuard (Evangelism-dept worker) Update convert status
GET /evangelism/converts/:id/follow-up-history?page=&limit= JwtAuthGuard (Evangelism-dept worker) Full follow-up log for a convert, newest first (mobile)
GET /evangelism/converts/admin?status=&page=&limit= AdminGuard (EVANGELISM_READ) Cross-member browse (admin portal)
PATCH /evangelism/converts/admin/:id/reassign AdminGuard (EVANGELISM_WRITE) Reassign follow-up to another Evangelism-dept worker
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)

| 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 | /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= | AdminGuard (GAMES_READ) | Paginated list, newest first. Each game carries activeSessionCode (non-null only while LIVE_SESSION_ACTIVE) | | GET | /admin/games/:id | AdminGuard (GAMES_READ) | Get a single game, with activeSessionCode | | 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 live session — 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 — 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 | | 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 | | 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 or already answered; 403 if caller never joined. | | GET | /games/sessions/:code/leaderboard | JwtAuthGuard + Module: games | Live leaderboard | | 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= | AdminGuard (VOLUNTEER_READ) | Paginated list, all statuses, newest date first | | 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? | | GET | /admin/small-groups?page=&limit= | AdminGuard (SMALL_GROUP_READ) | Paginated list, alphabetical by name | | PATCH | /admin/small-groups/:id | AdminGuard (SMALL_GROUP_WRITE) | Update any field; leaderId: null explicitly unassigns the leader | | 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) | | 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 | | POST | /event-config | AdminGuard (EVENTS_WRITE) | Create timing config | | PATCH | /event-config/:id | AdminGuard (EVENTS_WRITE) | Update timing config | | 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/: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 Pastor) | Cross-member browse (mobile) | | PATCH | /prayer-requests/team/:id/status | JwtAuthGuard (Prayer-dept worker or Pastor) | 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 Pastor) | Cross-member pregnancy prayer case browse (mobile) | | POST | /prayer-requests/team/pregnancy-cases | JwtAuthGuard (Prayer-dept worker or Pastor) | Create a pregnancy prayer case (mobile) | | POST | /prayer-requests/team/pregnancy-cases/:id/visit | JwtAuthGuard (Prayer-dept worker or Pastor) | Log a prayer visit (mobile) | | PATCH | /prayer-requests/team/pregnancy-cases/:id/status | JwtAuthGuard (Prayer-dept worker or Pastor) | Update case status (mobile) | | GET | /prayer-requests/team/pregnancy-cases/:id/visits | JwtAuthGuard (Prayer-dept worker or Pastor) | 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) | | PATCH | /classes/:id | AdminGuard (CLASSES_WRITE) | Update class | | 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) | | 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 } | | 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 | /notes/:type | AdminGuard (NOTES_READ) | List notes by type (types: child_naming, child_dedication, marriage, baptism) | | POST | /notes | AdminGuard (NOTES_WRITE) | Create note; optional memberId to link the record to a member’s own profile | | PUT | /notes/:id | AdminGuard (NOTES_WRITE) | Update note; memberId: null explicitly unlinks, omitting it leaves the existing link unchanged | | GET | /notes/:type/:id | AdminGuard (NOTES_READ) | Get note | | DELETE | /notes/:type/:id | AdminGuard (NOTES_WRITE) | Delete note | | GET | /notes-analytics/:type | AdminGuard (NOTES_READ) | Analytics for a note type | | GET | /members/me/milestones | JwtAuthGuard | The requesting member’s own linked rites-of-passage records (naming/dedication/marriage/baptism), newest first | | 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 | | 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 | | 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 | | PATCH | /sunday-school/sessions/:id/open | WORKER (SS-dept or class teacher) | Open self-mark window for N minutes (body: { closesInMinutes }) | | 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 | | GET | /sunday-school/sessions/:id/roster | WORKER (SS-dept or class teacher) | Get session attendance roster | | 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 | /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 | | 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 | | 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 | | 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 }) | | 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 | /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) | | POST | /tithes/me/statement/send | Any (JwtAuthGuard) | Email a PDF tithe statement to the caller’s registered email. Optional query: fromMonth (YYYY-MM), toMonth (YYYY-MM) — filters records to the date range and prints the period on the PDF | | POST | /tithes/proof | Any (JwtAuthGuard) | Submit tithe payment proof (multipart, field: file, max 2 MB); body: amount, paymentDate, bankName?, reference? | | 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; 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 | | GET | /admin/finance/requests | AdminGuard (FINANCE_READ) | List finance requests (paginated); filters: status, categoryId, memberId, departmentId, search | | 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) | | 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) | | 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 sessionCodenull until the programme’s session goes LIVE, then the code needed to call GET /service-session/:sessionCode/my-status. | | 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. | | 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). | | 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. | | 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. | | 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 (OPENIN_PROGRESSRESOLVED) 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) |

| 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:

  1. Load slot — fetches ServiceSlot with relations event, config, config.defaultVenue, venueOverride. Throws 404 if not found.

  2. Load member — fetches the authenticated member with workerProfile.

  3. Assert active — throws 400 if member.status = INACTIVE. Also throws if the member is a WORKER with workerProfile.status = INACTIVE.

  4. Worker location — workers must provide location coordinates. Throws 400 if location is absent for a WORKER.

  5. Duplicate check — throws 400 if an attendance record already exists for (member, event). One record per event, regardless of which slot the member enters.

  6. Resolve config — merges per-slot overrides over EventConfig values. Throws 400 if no config and no overrides.

  7. Validate window:

    • Workers: window opens at startTime + workerCheckinStartOffsetSeconds (typically negative)
    • Members: window opens at startTime + memberCheckinStartOffsetSeconds
    • Both close at startTime + checkinStopOffsetSeconds
  8. Validate location (if location provided): Resolves effectiveVenue ( slot.venueOverride ?? slot.config.defaultVenue). Calculates Haversine distance between submitted coordinates and the venue’s latitude/longitude. If distance exceeds allowedDistanceInMeters and ENFORCE_DISTANCE_CHECK=true, throws 400.

  9. Resolve status:

    • Member → always PRESENT
    • Worker before late threshold → PRESENT
    • Worker at or after startTime + workerLateOffsetSecondsLATE
  10. Save record — creates Attendance with references to both event and serviceSlot, roleAtCheckin snapshot, and optional location.


8. Automated Absence Marking

A cron job runs every 5 minutes (EVERY_5_MINUTES).

Logic:

  1. Finds all Event records where attendanceMarked = false AND endDate < today AND the event has at least one service slot.
  2. For each event:
    • Gets all members (ACTIVE, role=MEMBER) who have no PRESENT or LATE attendance record for the event → creates one ABSENT record per member referencing the event (serviceSlot = null).
    • Gets all workers (ACTIVE, role=WORKER) who have no PRESENT or LATE record for the event:
      • Checks request_leave table: if the worker has an APPROVED leave whose date_from ≤ event.eventDate ≤ date_to → creates ON_LEAVE record.
      • Otherwise → creates ABSENT record.
  3. All absence records for the event are saved in a single DB transaction.
  4. Sets event.attendanceMarked = true so the job skips it next run.
  5. Dispatches a post-event job to the follow-up Bull 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:

  1. Church role (MemberRoleEnum on the Member entity) — controls mobile-app routes: MEMBER or WORKER.
  2. Admin portal access (Admin entity + AdminRole permissions) — controls admin web portal routes via AdminGuard.

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 Pastor)
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).

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 10 Min idle connections kept alive
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
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

Email

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

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)

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 Require members to be within allowedDistanceInMeters to check in
ONLINE_CHECKIN_WINDOW_HOURS 3 Hours after online-confirm emails are sent during which members can confirm online attendance
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 MulterModule-wide default (AppModule) and the limit for routes with no more specific category below — incident report photos, member bulk-import spreadsheets
MAX_CLASS_MATERIAL_UPLOAD_BYTES 10485760 Training class study material uploads (ClassesController)
MAX_AVATAR_UPLOAD_BYTES 3145728 Small-image convention shared by tenant logo, tenant custom asset images, and member profile photo (TenantInfoController, MemberController)
MAX_FINANCE_PROOF_UPLOAD_BYTES 10485760 Finance request payment-proof attachments (FinanceWorkerController) — its own var rather than reusing MAX_CLASS_MATERIAL_UPLOAD_BYTES, despite the same default value, since the two are semantically unrelated
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 (optionally) a Data API key are not set here — they’re per-tenant, via PUT /v1/youtube-integration (see “YouTube Live Detection” above). All three below are platform-wide and all optional — leave unset to skip WebSub subscription entirely and rely on the Sermon Module’s manual “Announce Live” trigger instead.

Variable Default Description
YOUTUBE_API_KEY (optional) Fallback YouTube Data API v3 key, used only for a tenant that configured a channel but not their own key
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

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
DEFAULT_PAYMENT_PROVIDER paystack Which provider a checkout call uses when it doesn’t specify ?provider= explicitly
SUBSCRIPTION_PERIOD_DAYS 30 Flat renewal period CheckoutService.applyChargeSucceeded() extends currentPeriodEnd by per successful charge
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 · notes:read · notes: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”.

PastorTypeEnum

LEAD · PARISH · ASSOCIATE

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

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

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

No sections matched your search.