Complaints Backend Documentation
Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for Complaints.
Complaints Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/complaints/*.module.ts, apps/api/src/modules/complaints/*/*.module.ts, apps/api/src/app.module.ts | Aggregate/leaf composition, worker module isolation, MOBILE_CHILDREN/ADMIN_MODULES split. |
| Controllers | register/, workflow/, config/, attachments/, portal/*.controller.ts | Route surface, permissions, NO_PERMISSION_ADMIN_HANDLERS entries. |
| Services | shared/, register/, workflow/, config/, attachments/, portal/*.service.ts, *.commands.ts | Business logic, transaction boundaries, side effects. |
| DTOs | shared/dto/complaint-response.dto.ts, register/dto/, workflow/dto/, config/dto/, attachments/dto/, portal/dto/ | API contracts and validation. |
| Schema | packages/db/src/schema/school/complaints.ts (12 tables) | Tables, enums, CHECK constraints, indexes. |
| Migrations | packages/db/src/migrations/0039_complaints.sql, 0040_complaints_integrity.sql | DDL, triggers, deferred constraints, seed data. |
| Utils | apps/api/src/utils/complaints/complaint-lifecycle.util.ts, complaint-sla.util.ts, complaint-text.util.ts (+ their .spec.ts) | State machine, target/due-date arithmetic, text normalisation. |
| Jobs | packages/jobs/src/index.ts (QueueName.COMPLAINTS, ComplaintJob, ComplaintSweepPayload) | Queue contract. |
| Error codes | apps/api/src/common/types/error-codes.ts (COMPLAINT_*) | Every error code this module throws. |
| Permissions | packages/db/src/authorization/permission-catalog.ts (Complaint*, MyComplaints, SUPERADMIN_ONLY_MODULES, COMPLAINT_AUDIT_MODULES) | Permission modules, superadmin-only gates. |
| Guard allowlist | apps/api/src/common/authorization/role.guard.ts (NO_PERMISSION_ADMIN_HANDLERS) | Handlers that resolve visibility themselves. |
| Side channels | apps/api/src/modules/activity/recording/activity-audit.interceptor.ts, apps/api/src/modules/activity/activity.service.ts, apps/api/src/modules/notification/admin/history/notification-history.service.ts, apps/api/src/modules/upload/upload.service.ts | Anonymity leaks closed outside the module itself. |
| Tests | apps/api/src/modules/complaints/__tests__/*.integration.spec.ts, apps/api/src/utils/complaints/*.spec.ts | Coverage per surface. |
2. Backend Scope and Boundaries
Owns
- The complaint lifecycle from draft through submission, triage, handling, resolution, closure, reopening, rejection, cancellation and archival.
- Every configuration surface the process runs against: categories, templates, priorities, category×priority SLA rule overrides, the singleton general settings, per-kind reminder settings, per-event notification-channel settings, and escalation rules.
- The admin register: list, detail, event timeline, statistics, and the assignee picker.
- The complainant portal at
/api/mobile/complaints: filing (with category-specific questions and required fields), draft management, submission, the complainant's own follow-up actions (respond to an information request, confirm a resolution, reopen, cancel), and attachment upload/download scoped to the complainant's own complaints. - The evidence attachment pipeline shared by both surfaces: multer configuration, magic-byte sniffing, structural scanning, image re-encoding, refusal cleanup, temp-file cleanup, and download.
- The automated sweep: overdue flagging, reminders, escalation, and automatic closure, run every five minutes by one BullMQ worker.
- Anonymity: masking every identifying field on every read path except the complainant's own portal view, and the one audited reveal path.
- The permanent, append-only event history of every complaint.
Does Not Own
- Authentication, session issuance, and role/permission storage —
RoleModule, imported byComplaintsSharedModuleand re-exported so every controller'sRoleGuardcan injectRoleService. - Notification delivery and template rendering —
NotificationModule; this module only decides WHO gets notified and WHICH event key fired, and reads channel/enable settings back from its owncomplaint_notification_settingstable. - File storage and structural scanning infrastructure —
packages/storageandAssignmentFilesModule's scanning services, reused rather than re-implemented (imported byComplaintAttachmentCoreModule). - Academic-session, class, grade and section identity —
packages/db/src/schema/school/academic-sessions.tsandclasses.ts; complaints only snapshot the academic context at filing time viaComplaintPolicyService.academicContext, reusingAttendanceSharedModule's session/date resolution. - Student, guardian and staff identity —
packages/db/src/schema/school/people.ts; complaints referencestudents.id/staff.idand resolve audience membership through those tables but never writes to them. - The activity audit log and notification-history screens —
activityandnotification/admin/historyown those tables; this module only tells them which entries must be redacted.
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| Complaint state | complaints row plus its child tables (complaint_assignments, complaint_info_requests, complaint_resolutions) | Migration 0040's deferred constraint triggers keep the parent's derived columns (assigned_to_user_id, status) coherent with the child rows at commit. |
| Legal state transitions | apps/api/src/utils/complaints/complaint-lifecycle.util.ts | Pure; the database enforces coherence, this module enforces which MOVES are legal. |
| Visibility and per-action authorization | ComplaintAccessService (shared/complaint-access.service.ts), reading the caller's ACTIVE role only | Never trust a client-supplied capacity or visibility flag. |
| SLA targets and due dates | ComplaintPolicyService.resolveTargets + apps/api/src/utils/complaints/complaint-sla.util.ts | Precedence template → SLA rule → category → priority, computed server-side at submission and on every priority/category change. |
| Identity | Auth module's AuthUser.activeRole | scopeKind (`self |
| Anonymity masking | shared/complaint-masking.util.ts, applied by every response mapper | Never trust that a caller "already can't see it" — masking runs even for ComplaintIdentity_READ holders; only the reveal endpoint discloses. |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
ComplaintsModule | Aggregate | apps/api/src/modules/complaints/complaints.module.ts | None | None | None | Composes the four ADMIN leaves for app.module.ts. Declares no controller of its own. |
ComplaintsSharedModule | Shared/leaf | .../shared/complaints-shared.module.ts | None | ComplaintAccessService, ComplaintHistoryService, ComplaintNotifierService, ComplaintPeopleService, ComplaintPolicyService, ComplaintDraftService, ComplaintSubmissionService, ComplaintEffectsService | RoleModule + all providers | Everything every other leaf needs: visibility, history, notification routing, people/eligibility, policy/targets, drafting, submission, and workflow side-effect helpers. Imports RoleModule, NotificationModule, AttendanceSharedModule. |
ComplaintRegisterModule | Leaf, in ADMIN_MODULES | .../register/complaint-register.module.ts | ComplaintsController, ComplaintStatisticsController, ComplaintAssignableUsersController | ComplaintReadService, ComplaintListService, ComplaintOfficeRecordService, ComplaintStatisticsService, ComplaintPickerService | ComplaintReadService | The register: list, detail, event timeline, statistics, office-recorded filing, assignee picker. Exports ComplaintReadService so ComplaintWorkflowModule returns the identical detail shape after every command. |
ComplaintWorkflowModule | Leaf, in ADMIN_MODULES | .../workflow/complaint-workflow.module.ts | ComplaintActionsController, ComplaintOutcomeActionsController, ComplaintBulkController | ComplaintTriageCommands, ComplaintHandlingCommands, ComplaintOutcomeCommands, ComplaintWorkflowService, ComplaintBulkService | None | Every state-changing action, single and bulk. Imports ComplaintRegisterModule for ComplaintReadService. |
ComplaintAttachmentsModule | Leaf, in ADMIN_MODULES | .../attachments/complaint-attachments.module.ts | ComplaintAttachmentsController | None | None | The handler-side evidence upload route and the admin download route. Imports ComplaintAttachmentCoreModule for the actual pipeline. |
ComplaintAttachmentCoreModule | Shared, controller-less | .../attachments/complaint-attachment-core.module.ts | None | ComplaintAttachmentService, ComplaintAttachmentDownloadService | Both providers | The attachment pipeline itself, deliberately controller-less so both the admin leaf and the portal leaf can import it without the portal ever importing an admin controller. Imports UploadSharedModule, AssignmentFilesModule (reused scanning services). |
ComplaintConfigModule | Leaf, in ADMIN_MODULES | .../config/complaint-config.module.ts | Seven controllers (categories, templates, priorities, SLA rules, settings, escalation rules, notification settings) | Matching seven services | None | Every configuration surface. |
ComplaintPortalModule | Leaf, in MOBILE_CHILDREN only | .../portal/complaint-portal.module.ts | ComplaintPortalController, ComplaintPortalAttachmentsController, ComplaintPortalActionsController | ComplaintPortalAudienceService, ComplaintPortalProjectionService, ComplaintPortalQueryService, ComplaintPortalDraftService, ComplaintPortalActionsService | None | The complainant surface, mounted only at /api/mobile/complaints — never imported by ComplaintsModule. |
ComplaintWorkersModule | Composed by app.module.ts outside the fast profile | .../workers/complaint-workers.module.ts | None | ComplaintScheduler, ComplaintQueueProcessor, ComplaintSweepService | None | The sweep's @Cron producer and its one @Processor. |
4. File and Directory Map
apps/api/src/modules/complaints/
complaints.module.ts
attachments/
complaint-attachment-core.module.ts
complaint-attachment-download.service.ts
complaint-attachment-multer-error.filter.ts
complaint-attachment.multer.ts
complaint-attachment-refusal-cleanup.ts
complaint-attachment-scan.ts
complaint-attachments.controller.ts
complaint-attachment.service.ts
complaint-attachments.module.ts
complaint-attachment-sniff.ts
complaint-attachment-surface.ts
complaint-attachment-temp-file-cleanup.interceptor.ts
dto/complaint-attachment-upload.dto.ts
config/
complaint-categories.controller.ts / .service.ts
complaint-category-questions.util.ts
complaint-config.module.ts / complaint-config.util.ts
complaint-escalation-rules.controller.ts / .service.ts
complaint-notification-settings.controller.ts / .service.ts
complaint-priorities.controller.ts / .service.ts
complaint-settings.controller.ts / .service.ts
complaint-sla-rules.controller.ts / .service.ts
complaint-templates.controller.ts / .service.ts
dto/ (one request + one response DTO per surface, plus complaint-config-common.dto.ts)
portal/
complaint-portal.controller.ts / complaint-portal-draft.service.ts
complaint-portal-actions.controller.ts / .service.ts
complaint-portal-attachments.controller.ts
complaint-portal-audience.service.ts
complaint-portal.module.ts
complaint-portal-projection.service.ts / .util.ts
complaint-portal-query.service.ts
complaint-portal-identity.ts
complaint-portal.types.ts
dto/complaint-form-options.dto.ts, complaint-portal-attachment-upload.dto.ts, complaint-portal-request.dto.ts
register/
complaint-assignable-users.controller.ts
complaint-detail.mapper.ts / complaint-detail-query.ts / complaint-list-query.ts
complaint-list.service.ts / complaint-office-record.service.ts
complaint-picker.service.ts / complaint-read.service.ts
complaint-register.module.ts / complaint-result.util.ts
complaints.controller.ts
complaint-statistics.controller.ts / .service.ts
dto/ (assignable-users-query, complaint-statistics-query, list-complaints-query, list-events-query, query-boolean, record-complaint)
shared/
complaint-access.service.ts / complaint-draft.service.ts
complaint-effects.service.ts / complaint-errors.util.ts
complaint-filing.rules.ts / complaint-history.service.ts
complaint-masking.util.ts / complaint-notifier.service.ts
complaint-people.service.ts / complaint-policy.service.ts
complaints-shared.module.ts / complaint-submission.service.ts
dto/complaint-response.dto.ts
workers/
complaint-queue.processor.ts / complaint.scheduler.ts
complaint-sweep-anchors.ts / complaint-sweep-shared.ts
complaint-sweep-auto-close.pass.ts / -escalation.pass.ts / -overdue.pass.ts / -reminders.pass.ts
complaint-sweep.service.ts / complaint-workers.module.ts
workflow/
complaint-actions.controller.ts / complaint-outcome-actions.controller.ts / complaint-bulk.controller.ts
complaint-bulk.service.ts / complaint-command-base.ts
complaint-handling.commands.ts / complaint-outcome.commands.ts / complaint-triage.commands.ts
complaint-workflow.module.ts / complaint-workflow.service.ts
dto/complaint-action.dto.ts, complaint-bulk.dto.ts
__tests__/ (8 integration specs + fixtures + test-support)
apps/api/src/utils/complaints/
complaint-lifecycle.util.ts (+ .spec.ts)
complaint-sla.util.ts (+ .spec.ts)
complaint-text.util.ts (+ .spec.ts)For the highest-traffic files:
| File | Purpose | Key Exports | Notes |
|---|---|---|---|
shared/complaint-access.service.ts | Loads/locks a complaint row, decides visibility and every per-action permission | ComplaintAccessService, ACTION_PERMISSION | The single place Complaints_READ, ComplaintAssignments_READ, submitter/subject exclusion are combined. |
shared/complaint-masking.util.ts | Anonymity masking for every response projection | submitterView, actorView, attachmentNameView, contactView | Runs unconditionally; the reveal endpoint is the only bypass. |
utils/complaints/complaint-lifecycle.util.ts | The state machine | canPerform, nextStatus, isOverdue, availableActions | Pure; no NestJS/DB/clock. |
utils/complaints/complaint-sla.util.ts | Target precedence and due-date arithmetic | resolveTargets, computeDueAt, reminderSequenceDue, withinReopenWindow | Pure; caller passes now. |
attachments/complaint-attachment-surface.ts | Where an uploaded file lands, per surface | resolveAdminAttachmentPlacement, resolvePortalAttachmentPlacement | The one place admin/portal placement and authorization differ; the upload pipeline itself is shared. |
portal/complaint-portal-audience.service.ts | Resolves who is filing and whom they may name | ComplaintPortalAudienceService | Identity-layer-only lookups (deleted_at filter alone). |
workers/complaint-sweep-shared.ts | Generic keyset sweep loop with exactly-once claiming | runSweepPass, tryClaimFiring | Shared by all four sweep passes. |
5. Data Model
5.1 Schema Source
packages/db/src/schema/school/complaints.ts -- all 12 tables, all enums
packages/db/src/migrations/0039_complaints.sql -- table DDL
packages/db/src/migrations/0040_complaints_integrity.sql -- triggers, deferred constraints, seed data5.2 Tables and Collections
complaint_priority_settings
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
priority | complaint_priority enum PK | No | — | PK | — | low/medium/high/urgent; declaration order is the sort order. |
label, description | text | label No, description Yes | — | length CHECKs (40 / 500) | — | |
ackTargetMinutes, resolutionTargetMinutes | integer | No | — | range 1–525600, ack <= resolution | — | Minutes. |
alertOnSubmit | boolean | No | false | — | — | Sends complaint.urgent_submitted to triage instead of the ordinary alert. |
version | integer | No | 1 | — | — | Optimistic concurrency for this row's PATCH. |
updatedByUserId | uuid | Yes | — | FK users.id set null | — |
complaint_categories
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | serial PK | No | — | PK | — | Internal FK target. |
publicId | uuid | No | uuid7() | unique | — | Wire id. |
name | text | No | — | unique on lower(name) where not deleted; format CHECK ≤80 | — | |
isActive, isSystem | boolean | No | true / false | is_system never paired with deleted_at IS NOT NULL | — | Seeded categories are isSystem: renameable, deactivatable, never hard- or soft-deleted. |
applicableAudiences | text[] | No | all three | subset of student/guardian/staff | — | A teacher submits staff. |
defaultPriority | enum | No | medium | — | — | |
ackTargetMinutes, resolutionTargetMinutes | integer | Yes | — | range + ack<=resolution when both set | — | Level 2 of the SLA precedence. |
requiresAttachment, allowAnonymous | boolean | No | false | — | — | |
questions | jsonb | No | [] | array, ≤20 entries | — | Category-specific questions; snapshotted onto the complaint at submission. |
sortOrder | integer | No | 0 | 0–10000 | — | |
version | integer | No | 1 | — | — | |
deletedAt | timestamptz | Yes | — | — | — | Soft delete; blocked while isSystem. |
| FKs into this table | — | — | — | restrict | complaints, complaint_templates, complaint_sla_rules, complaint_escalation_rules | Never hard-deletable while referenced. |
complaint_sla_rules
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id/publicId | serial/uuid | No | — | PK / unique | — | |
categoryId | integer | No | — | restrict FK, unique with priority | complaint_categories.id | One override row per (category, priority) pair — level 2 in the precedence. |
priority | enum | No | — | — | — | |
ackTargetMinutes, resolutionTargetMinutes | integer | No | — | range + ack<=resolution | — | Hard-deletable — configuration, not history. |
complaint_templates
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id/publicId | serial/uuid | No | — | PK/unique | — | |
name | text | No | — | unique on lower(name) where not deleted; ≤80 | — | |
categoryId | integer | No | — | restrict FK | complaint_categories.id | |
titleHint, descriptionGuidance | text | Yes | — | length CHECKs (200 / 2000) | — | |
defaultPriority | enum | Yes | — | — | — | |
requiredFields | text[] | No | [] | subset of incident_date/incident_location/related_student/related_staff/attachments | — | Level 1 of SLA precedence for ack/resolution targets. |
ackTargetMinutes, resolutionTargetMinutes | integer | Yes | — | range + pairing | — | |
isActive, sortOrder, version | — | No | true/0/1 | sortOrder 0–10000 | — | |
deletedAt | timestamptz | Yes | — | — | — | Soft delete. |
complaint_settings (singleton, id=1)
| Column | Type | Default | Constraint | Notes |
|---|---|---|---|---|
id | smallint PK | 1 | id = 1 | Singleton row. |
anonymousEnabled | boolean | true | — | School-wide toggle. |
complainantCanSetPriority | boolean | false | — | |
closureMode | enum | manual | manual/automatic/complainant_confirmation | Applies to NEW resolutions only — see §16.6. |
autoCloseAfterHours | integer | 168 | 1–8760 | |
complainantCanReopen, reopenWindowDays | — | true / 30 | 1–365 days | |
complainantCanCancel | boolean | true | — | |
maxAttachmentSizeMb | integer | 10 | 1–25 | |
maxAttachments | integer | 5 | 1–10 | Per draft/message. |
maxTotalAttachments | integer | 20 | 1–100, >= maxAttachments | Across the complaint's whole life. |
dailySubmissionLimit | integer | 10 | 1–100 | Per submitter per school-local day. |
allowedExtensions | text[] | 10 defaults | subset of the 10 known extensions | |
version | integer | 1 | — |
complaint_reminder_settings
| Column | Type | Default | Constraint | Notes |
|---|---|---|---|---|
kind | text PK | — | one of 7 known kinds | unacknowledged, unassigned, pending_information, approaching_due, overdue, pending_closure, reopened. |
isEnabled | boolean | false | — | All 7 kinds seeded disabled. |
afterHours | integer | — | 0–8760 | Hours after the anchor; for approaching_due, hours BEFORE due. |
repeatHours | integer | — | 1–720 | |
maxReminders | integer | 3 | 1–20 |
complaint_notification_settings
| Column | Type | Default | Constraint | Notes |
|---|---|---|---|---|
eventKey | text PK | — | one of 20 known keys | See §9 for the full list. |
isEnabled | boolean | true | — | |
channels | text[] | — | subset of in_app/push/email/sms, enabled ⇒ ≥1 channel |
complaint_escalation_rules
| Column | Type | Nullable | Default | Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id/publicId | serial/uuid | No | — | PK/unique | — | |
name, isActive | text/boolean | No | true | length ≤80 | — | |
trigger | enum | No | — | unacknowledged/unassigned/no_response/unresolved/awaiting_information | — | |
afterMinutes | integer | No | — | 1–525600 | — | |
categoryId, priority | int/enum | Yes | — | restrict FK | complaint_categories.id | Null = any. |
notifyAssigneeManager, notifyTriage | boolean | No | true | — | — | Manager = staff.manager_id. |
recipientUserIds | uuid[] | No | [] | ≤20 entries; row-level CHECK requires at least one recipient path | — | |
deletedAt | timestamptz | Yes | — | — | — |
complaints (aggregate root)
| Column | Type | Nullable | Default | Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | uuidv7 PK | No | generated | PK | — | |
referenceNo | bigint | Yes | — | unique; IFF status='draft' is null; >0 | complaint_reference_seq | Allocated only at submission. Internal submission order: never returned, never searchable. |
reference | text | Yes | — | unique (complaints_reference_unique); ^CMP-[0-9A-HJKMNP-TV-Z]{8}$ (complaints_reference_format); null exactly when reference_no is null (complaints_public_reference_iff_numbered); never changes once set (complaints_reference_immutable, raised by complaints_guard_update) | — | The public reference, e.g. CMP-7K3QX9MB: CMP- and eight random Crockford base32 characters (no I, L, O or U). Assigned by the complaints_assign_reference BEFORE trigger in the statement that sets reference_no, from complaint_new_reference(). Random rather than sequential so a gap in the register cannot reveal that a complaint hidden from the reader exists, or when it was filed. |
status | enum, 11 values | No | draft | — | — | See §5.3 and §16.5. |
title | text | No | — | non-blank, no control chars, 3–200 chars | — | |
description | text | No | "" | ≤10000; ≥10 trimmed chars once past draft | — | |
categoryId, templateId | int | cat No, tmpl Yes | — | restrict FK | complaint_categories/complaint_templates | |
priority | enum | No | — | — | — | |
submitterUserId | uuid | No | — | restrict FK | users.id | Always recorded, even when anonymous — masking is on read, not absence. |
submitterKind | enum | No | — | student/guardian/teacher/staff/admin | — | |
recordedByUserId | uuid | Yes | — | set null FK | users.id | Office-recorded filing; portal hides it from the recorder when it equals the submitter. |
isAnonymous | boolean | No | false | ⇒ no related student; admin submitter cannot be anonymous | — | |
relatedStudentId, relatedStaffId | uuid | Yes | — | restrict FK | students.id/staff.id | |
academicSessionId, classId, gradeName, sectionName | int/text | Yes | — | restrict FK (session/class); class↔grade↔section all-null-or-all-set; class needs session and related student | academic_sessions/classes | Snapshotted at filing; a later promotion/transfer never rewrites these. |
incidentDate, incidentLocation, contactPreference | date/text | Yes | — | length CHECKs | — | |
answers, questionSnapshot | jsonb | No | {} / [] | object/array typeof | — | |
assignedToUserId, assignedAt | uuid/ts | Yes | — | paired; required while assigned/in_progress/waiting_for_information | users.id restrict | |
submittedAt, acknowledgedAt, firstResponseAt | timestamptz | Yes | — | submittedAt paired with non-draft; acknowledgedAt required past triage | — | |
ackDueAt, dueAt, dueSource | ts/ts/enum | Yes | — | dueAt↔dueSource paired; both required once submitted | — | dueSource names which precedence level supplied the RESOLUTION target. |
slaPausedAt, slaPausedSeconds | ts/bigint | Yes/No | —/0 | slaPausedAt iff waiting_for_information; seconds ≥0 | — | Paused seconds accumulate for the CURRENT cycle only. |
resolvedAt, closedAt | timestamptz | Yes | — | resolved/closed require resolvedAt; closedAt iff closed | — | |
rejectedAt, rejectionReason | ts/text | Yes | — | paired iff rejected; reason 3–2000 chars | — | |
duplicateOfComplaintId | uuid | Yes | — | self-FK restrict, not self, implies rejected | complaints.id | |
cancelledAt, cancellationReason | ts/text | Yes | — | paired iff cancelled; reason 3–2000 chars | — | |
reopenCount, lastReopenedAt | int/ts | No/Yes | 0 | reopened requires timestamp; counters ≥0 | — | The SLA cycle number. |
escalationLevel, lastEscalatedAt | int/ts | No/Yes | 0 | ≥0 | — | |
archivedAt | timestamptz | Yes | — | only from a terminal status | — | |
lastActivityAt | timestamptz | No | now() | — | — | Drives the open-complaints list ordering. |
submissionId | uuid | Yes | — | unique with submitterUserId where not null | — | Client idempotency key for draft creation. |
version | integer | No | 1 | — | — | Optimistic concurrency for every workflow command. |
Indexes: partial complaints_status_idx (status, last_activity_at desc) where not archived; partial complaints_assignee_idx; complaints_submitter_idx; partial complaints_due_open_idx for the 5 open statuses; complaints_category_idx; partial complaints_related_student_idx; complaints_session_idx; partial complaints_duplicate_of_idx; two gin_trgm_ops indexes on title/description for search.
complaint_assignments (append history)
| Column | Type | Constraint | Notes |
|---|---|---|---|
id | uuidv7 PK | — | |
complaintId | uuid, restrict FK | one active per complaint via partial unique index on ended_at IS NULL | |
assigneeUserId | uuid, restrict FK | — | |
assignedByUserId | uuid, set null FK | — | |
reason | text | 1–1000 chars | |
endedAt, endedByUserId, endReason | — | paired; endReason IN ('reassigned','unassigned'); endedAt >= assignedAt | Row is never deleted, only ended. |
complaint_info_requests
| Column | Type | Constraint | Notes |
|---|---|---|---|
id | uuidv7 PK | — | |
complaintId | restrict FK | one open per complaint via partial unique index (respondedAt IS NULL AND withdrawnAt IS NULL) | |
question | text | 3–5000 chars | |
responseText, respondedAt, respondedByUserId | — | paired; withdrawn XOR responded; response ≥ request time | |
withdrawnAt, withdrawnReason | — | paired |
complaint_resolutions
| Column | Type | Constraint | Notes |
|---|---|---|---|
id | uuidv7 PK | — | |
complaintId | restrict FK | one current per complaint via partial unique index (supersededAt IS NULL) | |
summary, actionTaken | text | 3–5000 chars each | |
followUpDate, followUpNotes | date/text | notes ≤2000 | Notes internal, never shown to complainant. |
closureMode | enum, No | — | Captured AT resolution time — switching the school's mode never mass-closes the backlog. |
supersededAt, supersededReason | — | paired | Set when the complaint is reopened; row never deleted. |
complaint_events (append-only permanent history)
| Column | Type | Constraint | Notes |
|---|---|---|---|
id | bigint identity PK | — | |
publicId | uuid, unique | — | |
complaintId | cascade FK | — | The one cascade in the module — draft discard removes its (nonexistent) events trivially; a submitted complaint cannot be deleted anyway (complaints_guard_delete). |
eventType | enum, 25 values | — | See §16.5. |
visibility | enum | — | public/internal. |
actorUserId, actorCapacity | uuid/enum | system iff actorUserId IS NULL | complainant/handler/administrator/system. |
fromValue, toValue, reason | text | ≤200/≤200/≤2000 | |
body | text | 1–10000; required when internal_note/public_response | |
infoRequestId, resolutionId | uuid | restrict FK | Optional links to the triggering child row. |
metadata | jsonb | object typeof |
Migration 0040's complaint_events_append_only trigger refuses every UPDATE and DELETE on this table.
complaint_attachments
| Column | Type | Constraint | Notes |
|---|---|---|---|
id | uuidv7 PK | — | |
complaintId | cascade FK | — | A discarded DRAFT takes its files' rows with it. |
eventId | bigint, restrict FK | — | Null for a standalone/draft upload. |
visibility, uploadedByUserId, uploaderCapacity | — | uploaderCapacity <> 'system' | System never uploads. |
storageKey | text, unique | — | |
originalName | text | 1–255 chars, no control// chars, no backslash | |
mimeType, extension, sizeBytes, sha256 | — | extension known; size 1–26214400 bytes; sha256 lowercase hex 64 |
complaint_automation_log
| Column | Type | Constraint | Notes |
|---|---|---|---|
id | bigint identity PK | — | |
complaintId | restrict FK | — | |
kind, ruleKey, cycle, sequence | text/text/int/int | unique together; kind one of 5 known; counters ≥0 | Exactly-once bookkeeping — one row per firing, never updated. |
firedAt | timestamptz | — |
5.3 Relationship Diagram
6. Services and Responsibilities
6.1 ComplaintAccessService (shared/complaint-access.service.ts)
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
permissionsOf(actor) | Every command and read path | RoleService.getPermissionsForRoleId | — | — | AUTH_ACTIVE_ROLE_REQUIRED if no active role. |
loadRow(executor, id, {lock}) | Every command and access check | complaints, staff (subject's userId) | — | Optional FOR NO KEY UPDATE on complaints only | — |
decide(actor, row, permissions) | resolveVisible | — | — | — | — |
resolveVisible(executor, actor, id, {lock}) | Every controller and command | as above | — | — | COMPLAINT_NOT_FOUND (visibility failure and nonexistence answer identically) |
heldActions(access) | Portal/admin "available actions" surfaces | ACTION_PERMISSION map | — | — | — |
listVisibilityWhere(actor, scope) | ComplaintListService, ComplaintStatisticsService (every count uses it, so the caller's own complaints and complaints about the caller are not counted for them; byClass.classId is the class publicId) | — | — | — | — |
Visibility rule: superadmin OR (NOT submitter AND NOT subject AND (Complaints_READ OR (ComplaintAssignments_READ AND current assignee))), evaluated against the caller's ACTIVE role only. An office record with nobody named (submitter_kind='admin' and recorded_by_user_id = submitter_user_id) has no complainant, so its recorder does not count as the submitter and reads it like any other handler; decide and listVisibilityWhere apply the same exception. The lock is FOR NO KEY UPDATE, never plain FOR UPDATE, because history/attachment inserts elsewhere take KEY SHARE on the same row and must not queue behind an unrelated lock.
6.2 Complaint lifecycle commands (workflow/complaint-triage.commands.ts, -handling.commands.ts, -outcome.commands.ts)
| Group | Actions Owned | Reads | Writes | Side Effects |
|---|---|---|---|---|
ComplaintTriageCommands | acknowledge, assign, reassign, unassign, start_investigation | complaints, complaint_assignments | complaints (status, assignedToUserId/assignedAt, acknowledgedAt), inserts/ends complaint_assignments rows | History event, notification to assignee/complainant |
ComplaintHandlingCommands | add_response, add_note, request_information, withdraw_information_request, change_priority, change_category, set_due_date | as above + complaint_info_requests | complaint_info_requests (open/withdraw), complaints (priority/category/due fields, SLA pause), events, one update_notice row per note day in complaint_automation_log | Notification; a private recomputeTargets re-derives dueAt (unless manual) and, while unacknowledged, ackDueAt on a priority or category change |
ComplaintOutcomeCommands | resolve, close, reopen, reject, mark_duplicate, cancel, escalate, archive, unarchive, reveal_identity | as above + complaint_resolutions | complaint_resolutions (insert/supersede), complaints (terminal fields), events | Notification, ComplaintEffectsService.reopen/.cancel for cross-table effects |
respond_to_information_request, confirm_resolution, reopen_by_complainant and the complainant's cancel live in ComplaintPortalActionsService (§7.4), not in these groups.
Every command follows the same order: ComplaintAccessService.resolveVisible with lock: true → canPerform from the lifecycle util (archived/not-archived/invalid-status refusal → COMPLAINT_ARCHIVED/COMPLAINT_NOT_ARCHIVED/COMPLAINT_INVALID_TRANSITION) → expected-version check (COMPLAINT_VERSION_CONFLICT) → input validation → write inside the same transaction as the version bump, the history event, and (where relevant) the child-table write → notification dispatch. ComplaintWorkflowService is the single entry point that ComplaintActionsController, ComplaintOutcomeActionsController and ComplaintBulkService call, so a bulk action can never drift from its single-item sibling. start checks that the caller is the current assignee (403 COMPLAINT_NOT_CURRENT_ASSIGNEE) before the transition check. After commit, buildCommandResult (register/complaint-result.util.ts) rebuilds the detail with a fresh access decision; if that read fails, it logs the failure and returns { complaint: null, eventId } rather than an error, because the write has already committed.
6.3 ComplaintSubmissionService (shared/complaint-submission.service.ts)
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
submitDraft(executor, complaintId, filer, actorUserId) | ComplaintPortalDraftService.create (with submit: true) and .submit | The draft row itself (re-reads title/description/category/template/priority/etc. from it), then delegates to the private finalise | complaints (referenceNo from complaint_reference_seq, status, submittedAt, ackDueAt, dueAt, dueSource) via finalise | History submitted event, two notifications (submitted_complainant, submitted_triage/complaint.urgent_submitted) | COMPLAINT_NOT_DRAFT itself; finalise additionally throws the errors listed on its row |
recordSubmitted(executor, filer, fields, submissionId, actorUserId) | ComplaintOfficeRecordService | — | Inserts the draft (ComplaintDraftService.insertDraft) and calls finalise in the same transaction, so no submitted-preceding history row exists; a replayed submissionId returns the existing complaint without re-running finalise | Same as finalise, plus draft insertion | COMPLAINT_NOT_DRAFT is not reachable here (the draft is freshly inserted); other finalise errors apply |
finalise(executor, complaintId, filer, fields, actorUserId) (private, does the actual work for both entry points) | submitDraft, recordSubmitted | complaint_settings, category/template usability (complaint-filing.rules.ts), attachment count when required | complaints UPDATE (status, referenceNo, normalised text, category/template, priority, academic context, schedule, version+1) | History submitted event; submitted_complainant then submitted_triage (kind complaint.urgent_submitted when the priority's alertOnSubmit is on, otherwise complaint.new_for_triage), sharing one requestId | COMPLAINT_ANONYMOUS_NOT_ALLOWED (anonymity disabled, category disallows it, or filer is admin/office-recorded), COMPLAINT_ANONYMOUS_STUDENT_NOT_ALLOWED, VALIDATION_FAILED (normalised lengths), the category and template errors from usableCategory/usableTemplate, COMPLAINT_INCIDENT_DATE_IN_FUTURE, COMPLAINT_REQUIRED_FIELD_MISSING, COMPLAINT_ANSWERS_INVALID, COMPLAINT_RELATED_STUDENT_NOT_FOUND/COMPLAINT_RELATED_STAFF_NOT_FOUND, COMPLAINT_ATTACHMENT_REQUIRED, COMPLAINT_NOT_DRAFT (the guarded UPDATE's WHERE status='draft' matching no row) |
finalise does not enforce the daily submission limit or lock/version-check the row — its own doc comment states those are the CALLER's responsibility ("What the CALLER has already decided and this service does not: who the submitter is and in what capacity; that a named related student is one this caller may name; the per-submitter limits; the lock on the row"). The portal enforces the limits in ComplaintPortalDraftService: create takes the per-submitter lock and checks the 20-draft limit, and checks the daily submission limit when submit is true; submit takes the lock and checks the daily limit. The office record path (ComplaintOfficeRecordService) applies neither limit.
6.4 ComplaintPolicyService (shared/complaint-policy.service.ts)
| Method | Purpose |
|---|---|
settings(executor) | Reads the complaint_settings singleton straight from Postgres; a missing row is a 500 SYS_INTERNAL_ERROR telling the operator to re-run migrations. |
reminderSettings(executor) | Reads all 7 complaint_reminder_settings rows. |
channelsFor(executor, eventKey) | Reads one complaint_notification_settings row; used by ComplaintNotifierService. |
resolveTargets(executor, categoryId, templateId, priority) | Loads template/SLA-rule/category/priority rows and delegates to resolveTargets in complaint-sla.util.ts. |
schedule(...) | Computes ackDueAt/dueAt/dueSource for a submission or a priority/category change, calling computeAckDueAt/computeDueAt. |
timezone(executor) | The school's IANA timezone from SchoolClockService.timezone(), which reads school_profile.timezone and defaults to Asia/Kathmandu. Every list and detail DTO carries it as schoolTimezone. |
schoolToday, startOfSchoolDay | School-timezone day boundaries for the daily submission limit, incident-date checks and statistics. |
sessionForDate, academicContext | Resolves the academic session/class/grade/section AS FILED — the class the student was in on the incident date (or submission date if none), never the class they moved to, reusing AttendanceSharedModule's date/session resolution. |
6.5 ComplaintEffectsService (shared/complaint-effects.service.ts)
| Method | Purpose |
|---|---|
clockResumeFields(...) | Returns the fields that leave waiting_for_information: slaPausedAt cleared, the paused time added to slaPausedSeconds, and dueAt recomputed unless it is manual. It returns {} when the clock is not paused. The caller merges them into the same UPDATE that changes the status, because complaints_paused_iff_waiting is an immediate CHECK. |
withdrawOpenRequest(...) | Withdraws the open complaint_info_requests row, if any, and appends an information_request_withdrawn event. It does not touch the clock; resolve and reject call clockResumeFields themselves. Returns whether a request was open. |
reopen(...) | Supersedes the current resolution; clears resolvedAt, closedAt, the rejection, duplicate and cancellation fields; keeps the assignee; sets acknowledgedAt if it was never set; increments reopenCount; sets lastReopenedAt as the new cycle start; resets the pause; recomputes dueAt and dueSource (a manual deadline does not carry over). Appends reopened and notifies reopened_handlers (the live assignee, or triage). |
cancel(...) | Withdraws an open information request (reason Cancelled), then sets cancelled, cancelledAt, cancellationReason and clears slaPausedAt, and appends cancelled. The assignment is left as it is. The caller notifies and bumps version. |
6.6 ComplaintHistoryService (shared/complaint-history.service.ts)
| Method | Purpose |
|---|---|
append(tx, ...) | Inserts one complaint_events row. Every workflow command calls this inside the same transaction as its state write — the history is never a best-effort side channel, unlike notification delivery. |
appendInternalDuplicateNote(tx, ...) | The marked_duplicate action's extra internal note recording the target complaint. |
6.7 ComplaintNotifierService (shared/complaint-notifier.service.ts)
| Method | Purpose |
|---|---|
notify(executor, notice) | Reads the event's channels (none when switched off), drops the actor and anyone not live, then makes one NotificationService.send call that writes notification_event and outbox rows in the caller's transaction, deduplicated on the complaint id and requestId. The link is /dashboard/my-complaints/<id> for the complainant and /dashboard/complaints/<id> for staff. Returns the number of recipients. |
assigneeOrTriage(...) | Resolves the current assignee, falling back to triage recipients when unassigned. |
triage(executor, input) | ComplaintPeopleService.triageUserIds: live users holding ComplaintTriage_UPDATE and Complaints_READ in the same role, excluding the complainant (ComplaintAccessService.complainantUserId: the submitter, or nobody for an office record with nobody named) and the subject. |
complainant(input) | The submitter, or nobody when the office recorded the complaint with no complainant account. |
escalationRecipients(...) | Named recipients, the assignee's manager (if notifyAssigneeManager), and triage (if notifyTriage), filtered to live handlers and excluding the complainant and the subject. If nobody is left it falls back to triage; if still nobody, it logs a WARN and the escalation is recorded without a notification. |
6.8 ComplaintPeopleService (shared/complaint-people.service.ts)
| Method | Purpose |
|---|---|
isEligibleAssignee(executor, userId, context) | False for the subject, a guardian of the related student, anyone not live or holding neither ComplaintAssignments_READ nor Complaints_READ through any role, and, on a non-anonymous complaint, the submitter. On an anonymous complaint the submitter check is skipped, so an assign or escalation choice can never be refused in a way that singles out the author; the submitter still cannot see the complaint, because ComplaintAccessService.decide excludes them. Every refusal is the same false, so the caller answers one code. |
listAssignable(executor, query) | The picker's data source: userId, name, designation for live users holding a handler code, matched by name, deliberately not filtered by any complaint's submitter. |
triageUserIds, liveOnly, eligibleRecipients, managerUserIdOf, isLive | Recipient helpers. A live user is not deleted, not banned and able to log in. managerUserIdOf reads staff.manager_id off the assignee's live staff row; the manager still has to pass eligibleRecipients. |
6.9 ComplaintDraftService (shared/complaint-draft.service.ts) and ComplaintPortalDraftService (portal/complaint-portal-draft.service.ts)
Draft creation, update and discard. A draft consumes no complaint_reference_seq value and writes no complaint_events row, which is what lets discard_draft remove it cleanly (its attachments cascade with it). submissionId is the idempotency key: a second POST with the same (submitterUserId, submissionId) pair hits the partial unique index and is treated as a resend of the same draft rather than a duplicate. The portal checks for a replay before any limit: ComplaintPortalDraftService.create takes the per-submitter lock, then findReplay returns an existing complaint with that submissionId unchanged, and only a new draft goes on to the 20-draft and daily limits.
7. Runtime Flows
7.1 Complainant files and submits a complaint
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | ComplaintPortalController.formOptions | Returns categories/templates applicable to the resolved audience | — |
| 2 | ComplaintPortalAudienceService.resolve | Identity-layer lookup of the caller's own student/guardian/staff row | COMPLAINT_AUDIENCE_UNRESOLVED |
| 3 | ComplaintPortalDraftService.createDraft | Validates category applicability, anonymous eligibility, required fields not yet enforced (draft) | COMPLAINT_CATEGORY_NOT_APPLICABLE, COMPLAINT_ANONYMOUS_NOT_ALLOWED, COMPLAINT_ANONYMOUS_STUDENT_NOT_ALLOWED, COMPLAINT_DRAFT_LIMIT_REACHED |
| 4 | ComplaintSubmissionService.submitDraft | Enforces required fields/answers/attachment, daily submission limit, computes SLA targets and due dates | COMPLAINT_REQUIRED_FIELD_MISSING, COMPLAINT_ANSWERS_INVALID, COMPLAINT_ATTACHMENT_REQUIRED, COMPLAINT_SUBMISSION_LIMIT_REACHED, COMPLAINT_INCIDENT_DATE_IN_FUTURE |
| 5 | ComplaintHistoryService.append | Writes the submitted event inside the same transaction | — |
| 6 | ComplaintNotifierService.notify | Notifies triage (and complainant, per settings); urgent-priority categories fire the alert variant | Best-effort — never fails the submission |
7.2 Handler acknowledges, assigns, investigates, resolves, closes
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | ComplaintTriageCommands.acknowledge | Requires submitted status | COMPLAINT_INVALID_TRANSITION |
| 2 | .assign | Refuses when an active assignment exists; acknowledges first if never acknowledged; inserts the active complaint_assignments row | COMPLAINT_ASSIGNEE_INELIGIBLE, COMPLAINT_ALREADY_ASSIGNED |
| 3 | .start (start_investigation) | Caller must be the current assignee | COMPLAINT_NOT_CURRENT_ASSIGNEE |
| 4 | ComplaintHandlingCommands.addResponse/.addNote | Moves assigned/reopened → in_progress only if caller is the current assignee, per nextStatus | — |
| 5 | ComplaintOutcomeCommands.resolve | Inserts a complaint_resolutions row capturing the CURRENT closureMode; status → resolved | COMPLAINT_INVALID_TRANSITION |
| 6 | .close | Requires resolved; sets closedAt | COMPLAINT_INVALID_TRANSITION |
7.3 Waiting for information (SLA pause) and resume
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | ComplaintHandlingCommands.requestInformation | From assigned/in_progress/reopened only; status → waiting_for_information, slaPausedAt set | COMPLAINT_INFORMATION_REQUEST_OPEN (only one open request per complaint) |
| 2 | Complainant respondToInformationRequest (portal complaint-portal-actions.controller.ts) | Records responseText/respondedAt; ComplaintEffectsService.clockResumeFields accumulates slaPausedSeconds; status → in_progress | COMPLAINT_INFORMATION_REQUEST_NOT_FOUND |
| 3 | withdrawInformationRequest (handler side) | Same clock-resume path, withdrawnAt/withdrawnReason instead of a response | — |
7.4 Reopen, reject, duplicate, cancel
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | ComplaintOutcomeCommands.reopen | Staff-side; from resolved/closed/rejected/cancelled. ComplaintEffectsService.reopen supersedes the resolution and starts a new SLA cycle | COMPLAINT_INVALID_TRANSITION, COMPLAINT_VERSION_CONFLICT |
| 2 | ComplaintPortalActionsService.reopen (reopen_by_complainant) | Only from resolved/closed, only when complainantCanReopen is on, and only within reopenWindowDays of the current resolution (withinReopenWindow) | COMPLAINT_REOPEN_NOT_ALLOWED, COMPLAINT_REOPEN_WINDOW_EXPIRED |
| 3 | .reject / .markDuplicate | From any ACTIVE_STATUS; from waiting_for_information both withdraw the open request and resume the clock. markDuplicate also validates the target, writes an internal note on it via appendInternalDuplicateNote, and defaults the reason to the fixed text Marked as a duplicate of another complaint. The detail's duplicateOf and duplicates, and the marked_duplicate event's toValue, appear only to callers who can open the target (Complaints_READ, or its assignee); the portal never shows them and returns duplicateOfReference: null | COMPLAINT_DUPLICATE_TARGET_INVALID |
| 4 | .cancel / portal cancel | Withdraws an open information request and stops the clock via ComplaintEffectsService.cancel; the assignment stays. Staff cancel notifies cancelled_complainant, a complainant cancel notifies cancelled_handlers | COMPLAINT_CANCEL_NOT_ALLOWED (portal only, when complainantCanCancel is off) |
7.5 Identity reveal
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | ComplaintOutcomeCommands.revealIdentity | Requires ComplaintIdentity_READ (superadmin-only module — see §11); does not change status | COMPLAINT_NOT_ANONYMOUS if the complaint was never anonymous |
| 2 | — | Writes an identity_viewed history event naming the revealing user, so the reveal is itself part of the permanent record | — |
| 3 | — | The response is the one place submitterView's masking is bypassed for this caller, for this one call | — |
7.6 Attachment upload (shared pipeline, surface-specific placement)
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | complaint-attachment.multer.ts | A fixed 25 MB ceiling, one file per request, and the complaint-attachment extension and MIME allowlist from UPLOAD_ALLOWLIST; the school's own maxAttachmentSizeMb and allowedExtensions are checked in ComplaintAttachmentService.run | COMPLAINT_ATTACHMENT_TOO_LARGE (413) and COMPLAINT_ATTACHMENT_TYPE_NOT_ALLOWED (400), shaped by complaint-attachment-multer-error.filter.ts |
| 2 | adminAttachmentPrecheck / portalAttachmentPrecheck | Fast, unlocked visibility/ownership check before doing any file work | COMPLAINT_NOT_FOUND |
| 3 | complaint-attachment-sniff.ts | Magic-byte check against the claimed extension | COMPLAINT_ATTACHMENT_TYPE_NOT_ALLOWED |
| 4 | complaint-attachment-scan.ts | A structural scan through the shared AssignmentScanWorkerPool with a 20-second timeout. A rejection is a 422 with a fixed message, never the scanner's detail; a throw or timeout is a 503. Images are then re-encoded, which strips EXIF; an image that does not decode is refused as 400 COMPLAINT_ATTACHMENT_REJECTED, and the recorded size_bytes is the re-encoded file's | COMPLAINT_ATTACHMENT_REJECTED, COMPLAINT_ATTACHMENT_SCAN_UNAVAILABLE |
| 5 | resolveAdminAttachmentPlacement / resolvePortalAttachmentPlacement | Runs inside the upload transaction on the locked complaint row: re-checks visibility or ownership, the portal's anonymous-type rule, resolves visibility/eventId/uploaderCapacity, then the per-message and lifetime counts are taken in the same transaction | COMPLAINT_ATTACHMENT_EVENT_INVALID, COMPLAINT_ATTACHMENT_VISIBILITY_MISMATCH, COMPLAINT_ATTACHMENT_ANONYMOUS_TYPE_REFUSED, COMPLAINT_ATTACHMENT_LIMIT_REACHED |
| 6 | ComplaintAttachmentService.run | Hashes the stored bytes (SHA-256), writes to storage, then inserts the row with extension, MIME, size and hash | COMPLAINT_ATTACHMENT_STORAGE_UNAVAILABLE |
| 7 | complaint-attachment-temp-file-cleanup.interceptor.ts | Removes the local temp file regardless of outcome | — |
| 8 | complaint-attachment-refusal-cleanup.ts | On any refusal after bytes were written (sniff/scan/storage failure), removes what was already persisted | — |
7.7 The automated sweep (every 5 minutes)
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | ComplaintScheduler.enqueueSweep (@Cron(EVERY_5_MINUTES)) | jobId built via buildJobId(["complaints-sweep", bucket]) where bucket = Math.round(now/300000); rounding rather than flooring keeps a cron callback that fires a millisecond early in its own bucket. The bucket width is the effective interval, and the bucket also rotates the starting reminder kind and escalation rule | Enqueue failure is swallowed and logged; no accompanying DB write (documented cron exemption from the outbox rule) |
| 2 | ComplaintQueueProcessor.process | Record<ComplaintJob, handler> — a job name with no handler throws rather than silently completing | Unhandled job name throws |
| 3 | runOverduePass | Flags OPEN_STATUSES complaints past dueAt; fires the overdue event and overdue_assignee once per SLA cycle, to the live assignee and triage, de-duplicated | Per-candidate failure logged, loop continues |
| 4 | runRemindersPass | Per enabled reminder kind, starting at a kind rotated by the tick bucket, computes reminderSequenceDue from the kind's anchor (submission, unassigned time via complaint-sweep-anchors.ts, active-assignment time, due date or resolution). The page query already excludes complaints below the time threshold or at maxReminders firings logged for the current anchor. Staff reminders go to the live assignee and triage, de-duplicated | Same |
| 5 | runEscalationPass | Walks active rules starting at one rotated by the tick bucket, pages only complaints whose anchor plus afterMinutes has passed, computes recipients via ComplaintNotifierService.escalationRecipients, and bumps escalationLevel, version and lastActivityAt | Same |
| 6 | runAutoClosePass | Runs only while the school's closureMode is automatic; the page query selects complaints whose current resolution has closure_mode='automatic' and resolved_at at or before the cutoff | Same |
| 7 | Every pass | Claims via tryClaimFiring (complaint_automation_log, unique on complaint+kind+ruleKey+cycle+sequence) — only the winning transaction applies the effect | Race losers simply skip |
| 8 | runRetentionPass (complaint-sweep-retention.pass.ts) | After the four effect passes and outside their budget, one bounded DELETE of up to AUTOMATION_LOG_PURGE_BATCH (1000) complaint_automation_log rows older than AUTOMATION_LOG_RETENTION_DAYS (90) that can never block a claim: cycle < reopen_count, the complaint is closed/rejected/cancelled, or the row is an update_notice. The count is the summary's automationLogPurged | A failure counts as one sweep failure and is logged; nothing else is affected |
8. Caching
| Cache Key Pattern | Builder | Value | TTL | Invalidation | Caller |
|---|---|---|---|---|---|
REDIS_KEYS.COMPLAINTS_SWEEP_LAST_SUCCESS | Direct REDIS_KEYS constant (not CacheKeyUtil — a single global marker, not a per-entity key) | ISO timestamp string | 7 days | Written only for a tick with zero failures, so a stale value means failing ticks as well as missing ones | ComplaintSweepService.run, via RedisCacheService.setSoft (best-effort; a write failure is logged, never thrown) |
No other Redis caching exists in this module. Configuration reads (complaint_settings, priorities, reminder/notification settings) go straight to Postgres on every call — these are small, infrequently-read rows and the module does not warrant a cache-invalidation surface for them (see §16.6).
9. BullMQ, Schedulers, and Async Work
| Queue | Job | Producer | Processor | Payload | Retry/Backoff | Idempotency |
|---|---|---|---|---|---|---|
QueueName.COMPLAINTS | ComplaintJob.SWEEP ("complaints.sweep") | ComplaintScheduler.enqueueSweep (@Cron(EVERY_5_MINUTES)) | ComplaintQueueProcessor (concurrency: 1) | ComplaintSweepPayload { v: 1, requestId: string } | attempts: 1, no dead-letter recording — the sweep is idempotent and re-derived from the database on every tick, so a failed attempt needs no replay | jobId = buildJobId(["complaints-sweep", bucket]), 5-minute bucket; per-firing exactly-once via complaint_automation_log |
The 20 notification event keys (complaint_notification_settings): submitted_complainant, submitted_triage, acknowledged_complainant, assigned_assignee, assigned_complainant, reassigned_previous_assignee, information_requested_complainant, information_provided_assignee, response_added_complainant, investigation_update_complainant, overdue_assignee, escalated_recipients, resolved_complainant, closed_complainant, reopened_handlers, rejected_complainant, cancelled_handlers, cancelled_complainant, reminder_staff, reminder_complainant. Each row's channels (subset of in_app/push/email/sms) and isEnabled gate ComplaintNotifierService.notify; apps/api/src/modules/notification/templates/complaint-templates.ts supplies the rendering, and packages/db/src/notification/notification-contract.ts types the payload shape per key.
10. Realtime and Events
This module does not participate in the realtime/SSE transport (modules/realtime). The complaint's own event stream (complaint_events) is a pull surface — GET /complaints/:id/events — not pushed.
11. Security, Auth, and Abuse Controls
- Guards: every controller sits behind the global
JwtAuthGuardandRoleGuard. Most routes declare@Permissions(...); four handlers are permission-less by design and resolve visibility themselves inside the handler, each requiring an entry in bothNO_PERMISSION_ADMIN_HANDLERS(role.guard.ts) and its CI mirror (route-permissions.spec.ts):ComplaintsController.findOne— visibility fromComplaintAccessService(Complaints_READOR current-assigneeComplaintAssignments_READ), excluding the complainant and the subject; the recorder of an office record with nobody named is not its complainant.ComplaintsController.listEvents— same visibility, further filtered per-event byComplaintHistory_READ/ComplaintInternalNotes_READ.ComplaintAttachmentsController.download— declares no@Permissions(); the source comment says not to add one, because a declared permission would override the allowlist and refuse every assignee without it.ComplaintAttachmentDownloadService.downloadForAdminresolves the owning complaint throughComplaintAccessService.resolveVisible(Complaints_READ, orComplaintAssignments_READas the current assignee; never the submitter or the subject; a missing active role is403 AUTH_ACTIVE_ROLE_REQUIRED). The attachment must belong to that complaint, and an internal attachment also needsComplaintInternalNotes_READ; otherwise404 COMPLAINT_ATTACHMENT_NOT_FOUND. Upload stays gated byComplaintAttachments_CREATE.ComplaintAssignableUsersController.list— requires ANY ofComplaintAssignments_CREATE,ComplaintAssignments_UPDATE,ComplaintEscalation_UPDATE,ComplaintSettings_UPDATE; returns only{userId, name, designation}, not scoped to any one complaint's submitter.
- Permission modules:
Complaints(the register, recording, manual due date, archive),MyComplaints(the complainant's own surface — every portal route),ComplaintAssignments,ComplaintTriage,ComplaintPriorityChange,ComplaintCategoryChange,ComplaintResponses,ComplaintInternalNotes,ComplaintInformationRequests,ComplaintAttachments,ComplaintResolution,ComplaintClosure,ComplaintReopen,ComplaintRejection,ComplaintCancellation,ComplaintEscalation,ComplaintHistory,ComplaintIdentity,ComplaintStatistics,ComplaintCategories,ComplaintTemplates,ComplaintPriorities(also gates SLA rules),ComplaintSettings(also gates escalation rules),ComplaintNotificationSettings,ComplaintView(the panel hub screen, zero API routes),ComplaintAudit. - Superadmin-only modules:
ComplaintIdentityandComplaintAuditare inSUPERADMIN_ONLY_MODULES— a disclosure about a child or colleague cannot be taken back, so neither is part of the automaticadmingrant; a school must grant either down to a named role explicitly. - Portal audience:
ComplaintPortalAudienceService.resolvederives the filer's kind (student/guardian/teacher/staff/admin) fromactiveRole.scopeKindplus a livestaffrow lookup, following the repo-wide portal rule — every lookup here is identity-layer only (deleted_atfilter alone), because a failure here is a 404 against the caller's own audience. - Row-scope authorization on the register:
ComplaintAccessService.listVisibilityWherebuilds theWHEREpredicate directly — never throughPeopleAccessService.scopeFor, whose{kind:"all"}would hand the entire register to anyStudents_READholder (the repo-wide portal rule's warning applies verbatim here). - Anonymity:
shared/complaint-masking.util.tsmaskssubmitterView,actorView,attachmentNameViewandcontactViewunconditionally on every admin-surface read, including forComplaintIdentity_READholders. The only bypass is the auditedrevealIdentityaction. - Rate limits:
complaint_settings.dailySubmissionLimit(default 10, school-local day viaComplaintPolicyService.schoolToday) is the module's own abuse control on submission volume, enforced byComplaintDraftService.assertDailySubmissionLimitunder a per-submitter lock when the portal submits; a submitter may also hold at most 20 open drafts (COMPLAINT_MAX_OPEN_DRAFTS). The office record path applies neither limit. - Input normalisation:
complaint-text.util.tsremoves the invisible set (zero-width and bidirectional controls, the BOM, the soft hyphen, variation selectors U+FE00-U+FE0F, Hangul and other fillers, and the tag block U+E0000-U+E007F) and C0 controls other than tab, LF and CR from every complainant-supplied text field, including free-text answers, and from attachment file names. A bidirectional override can makephoto<RLO>fdp.exedisplay asphotoexe.pdf; tag characters can hide text a handler never sees. - Anonymous attachments: a complainant-capacity upload on an anonymous complaint accepts only
jpg,jpeg,png,webpandtxt(attachments/complaint-attachment-anonymity.ts). Document formats carry the author's name in their own properties even when the text never names them; images are re-encoded, which drops their metadata, and plain text has none. The check runs at upload (resolvePortalAttachmentPlacement, on the locked row), at submit, and on a draft save as anonymous (assertNoAnonymousUnsafeAttachments), answering422 COMPLAINT_ATTACHMENT_ANONYMOUS_TYPE_REFUSED. Files a handler attaches are not affected. - Upload paths and logs: multer writes each upload to
uploads/complaint-attachment/<uuid>/with a fresh uuid per upload and a uuid file name, so the path identifies no one, and refuses a destination that resolves outside the upload root. Storage keys are never logged beside a complaint id. The allowlist includesapplication/mswordandapplication/vnd.ms-excelfor.docand.xls. - Audit trail closures (all outside this module's own files, closing anonymity leaks in shared infrastructure):
activity-audit.interceptor.ts:85-86—isComplaintAuditModule(module)redactsactor.endpointviaredactEndpointbefore the activity log records it.activity.service.ts:39-93—activityModuleFilterreturns an empty result set for any complaint module requested by a caller who is not superadmin and does not holdComplaintAudit_READ.notification-history.service.ts— unless the caller is superadmin or holdsComplaintAudit_READ, events of categorycomplaintdo not exist on the notification-history screen: the list leaves them out and the detail answers the same 404 as an unknown id. A complaint notification's audience is the complainant, and its title, reference and link reveal that a complaint exists, including to the staff member it is about. HoldingComplaintAudit_READis therefore equivalent to identity access.upload.service.ts:69-71— anUpload_READholder cannot browse complaint files through the generic upload surface; only the complaint's own visibility and attachment-visibility checks gate a complaint file.
13. Error Handling
Every code comes from apps/api/src/common/types/error-codes.ts. Database refusals (a CHECK, a unique index, a trigger raising SQLSTATE 23514 with a constraint name) pass through translateComplaintError in shared/complaint-errors.util.ts, which maps the known constraint names to the codes below and rethrows anything already an HTTP exception unchanged.
| Error Code | HTTP Status | Thrown By | Condition | Client Action |
|---|---|---|---|---|
COMPLAINT_NOT_FOUND | 404 | ComplaintAccessService.notFound, loadOwnComplaintRow, attachment surface and download service | Missing, a draft on the admin surface, invisible to the caller, or not the caller's own on the portal; all answer identically | Refresh; do not retry. |
COMPLAINT_VERSION_CONFLICT | 409 | assertVersion; ComplaintDraftService.insertDraft | expectedVersion is stale; or a submissionId insert conflicted with no own row to replay | Reload and re-apply. |
COMPLAINT_INVALID_TRANSITION | 409 | assertCanPerform; withdrawInformationRequest | Action not legal from the current status; the request is no longer open | Reload. |
COMPLAINT_ARCHIVED | 409 | assertCanPerform | Any action other than unarchive or reveal on an archived complaint | Unarchive first. |
COMPLAINT_NOT_ARCHIVED | 409 | assertCanPerform | Unarchive on a complaint that is not archived | Reload. |
COMPLAINT_ARCHIVE_TOO_EARLY | 409 | ComplaintOutcomeCommands.archive | A closed complaint is still inside the complainant's reopen window while reopening is on | Archive after reopenWindowDays. |
COMPLAINT_CONFIG_VERSION_CONFLICT | 409 | assertConfigVersion in complaint-config.util.ts | A config row's expectedVersion is stale | Reload and re-apply. |
COMPLAINT_CATEGORY_NOT_FOUND | 404 | Config services, filing rules, changeCategory | Unknown or deleted category | Pick another. |
COMPLAINT_CATEGORY_INACTIVE | 400 | Filing rules, template service, changeCategory | The category is deactivated | Pick another. |
COMPLAINT_CATEGORY_NOT_APPLICABLE | 400 | usableCategory in complaint-filing.rules.ts | Not offered to the filer's audience | Pick another. |
COMPLAINT_CATEGORY_NAME_ALREADY_EXISTS | 409 | Constraint translation (complaint_categories_name_unique) | Case-insensitive duplicate among live categories | Rename. |
COMPLAINT_CATEGORY_SYSTEM_PROTECTED | 409 | ComplaintCategoriesService.remove, constraint translation | Deleting an isSystem category | Deactivate instead. |
COMPLAINT_CATEGORY_HAS_TEMPLATES | 409 | ComplaintCategoriesService.remove, constraint translation | A live template still names the category | Delete or move the templates. |
COMPLAINT_CATEGORY_QUESTIONS_INVALID | 400 | complaint-category-questions.util.ts | Question list shape, keys, types or options invalid | Fix the questions. |
COMPLAINT_TEMPLATE_NOT_FOUND | 404 | Template service, filing rules | Unknown or deleted template | Pick another. |
COMPLAINT_TEMPLATE_INACTIVE | 400 | Filing rules | The template is deactivated | Pick another. |
COMPLAINT_TEMPLATE_NAME_ALREADY_EXISTS | 409 | Constraint translation (complaint_templates_name_unique) | Duplicate live template name | Rename. |
COMPLAINT_TEMPLATE_CATEGORY_MISMATCH | 400 | Filing rules | The template belongs to another category | Pick a matching template. |
COMPLAINT_SLA_RULE_NOT_FOUND | 404 | ComplaintSlaRulesService | Unknown rule | Reload. |
COMPLAINT_SLA_RULE_ALREADY_EXISTS | 409 | Constraint translation (complaint_sla_rules_one_per_pair) | A rule exists for that category and priority | Edit the existing rule. |
COMPLAINT_ESCALATION_RULE_NOT_FOUND | 404 | ComplaintEscalationRulesService | Unknown rule | Reload. |
COMPLAINT_ESCALATION_RECIPIENT_INVALID | 422 | Escalation rules service, ComplaintOutcomeCommands.escalate | A named recipient is not a live handler (or is the submitter or subject on a manual escalation), or a manager-only rule uses unacknowledged or unassigned | Choose other recipients. |
COMPLAINT_ANONYMOUS_NOT_ALLOWED | 400 | ComplaintSubmissionService (finalise) | Anonymity off for the school or category, filer is admin, or the complaint is office-recorded | File non-anonymously. |
COMPLAINT_ANONYMOUS_STUDENT_NOT_ALLOWED | 400 | Filing rules, draft service, constraint translation | Anonymous and naming a related student | Drop one. |
COMPLAINT_PRIORITY_NOT_SELECTABLE | 400 | ComplaintDraftService.assertPriorityChoosable, from portal draft create and update | A student, guardian, staff or teacher complainant sent a priority while complainant_can_set_priority is off. An administrator filing for themselves may always choose. If the setting is switched off after a draft saved a priority, submission quietly applies the default instead | Leave priority out. |
COMPLAINT_ATTACHMENT_ANONYMOUS_TYPE_REFUSED | 422 | complaint-attachment-surface.ts, assertNoAnonymousUnsafeAttachments | A complainant-uploaded file other than jpg, jpeg, png, webp or txt on an anonymous complaint: at upload, at submit, or on a draft saved as anonymous | Remove the file, or file with your name. |
COMPLAINT_SUBMITTER_KIND_CHANGED | 409 | ComplaintPortalDraftService.submit | The active role's audience differs from the one the draft was filed under | Switch back to that role. |
COMPLAINT_RELATED_STUDENT_NOT_FOUND | 404 | Filing rules, ComplaintPortalAudienceService.authoriseRelatedStudent | No live student, or one the caller may not name | Pick an allowed student. |
COMPLAINT_RELATED_STAFF_NOT_FOUND | 404 | Filing rules | No live staff row | Pick another. |
COMPLAINT_REQUIRED_FIELD_MISSING | 400 | Filing rules, submission | A template-required field or a required question is empty | Complete the form. |
COMPLAINT_ANSWERS_INVALID | 400 | assertAnswers in filing rules | An answer does not fit the question snapshot | Fix the answers. |
COMPLAINT_ATTACHMENT_REQUIRED | 400 | Submission | The category or template requires a file and none is attached | Upload to the draft first. |
COMPLAINT_INCIDENT_DATE_IN_FUTURE | 400 | Submission | incidentDate is after the school-local today | Fix the date. |
COMPLAINT_NOT_DRAFT | 409 | Submission, portal draft service | Edit, discard or submit on a complaint that is not a draft | Reload. |
COMPLAINT_DRAFT_LIMIT_REACHED | 409 | ComplaintDraftService.assertDraftLimit | 20 open drafts (COMPLAINT_MAX_OPEN_DRAFTS) | Submit or discard one. |
COMPLAINT_SUBMISSION_LIMIT_REACHED | 429 | ComplaintDraftService.assertDailySubmissionLimit | dailySubmissionLimit reached for the school-local day | Try tomorrow. |
COMPLAINT_AUDIENCE_UNRESOLVED | 403 | ComplaintPortalAudienceService.resolve | A student or guardian role with no live profile row | Contact the office. |
COMPLAINT_COMPLAINANT_INVALID | 422 | ComplaintOfficeRecordService | No live profile of that kind, or the account is deleted, banned or cannot log in; one code for every case | Check the person. |
COMPLAINT_COMPLAINANT_IS_RECORDER | 422 | ComplaintOfficeRecordService | The named complainant's account is the recorder's own. Such a record would be hidden from its recorder in both the register and the portal | File it from My complaints. |
COMPLAINT_ASSIGNEE_INELIGIBLE | 422 | ineligibleAssignee() from the triage commands | isEligibleAssignee returned false (the submitter counts only on a non-anonymous complaint); one code for every reason | Choose from the picker. |
COMPLAINT_ALREADY_ASSIGNED | 409 | Triage commands, constraint translation (complaint_assignments_one_active) | Assign while an assignment is active | Use reassign. |
COMPLAINT_NOT_ASSIGNED | 409 | Triage and handling commands | Reassign, unassign or request information with no current assignee | Assign first. |
COMPLAINT_SAME_ASSIGNEE | 409 | reassign | The new assignee is the current one | Pick someone else. |
COMPLAINT_NOT_CURRENT_ASSIGNEE | 403 | start | The caller is not the assignee | Only the assignee starts. |
COMPLAINT_INFORMATION_REQUEST_OPEN | 409 | requestInformation, constraint translation (complaint_info_requests_one_open) | A request is already open | Wait or withdraw it. |
COMPLAINT_INFORMATION_REQUEST_NOT_FOUND | 404 | withdrawInformationRequest, portal respond | Unknown request; on the portal also an answered or withdrawn one | Reload. |
COMPLAINT_CLOSURE_REQUIRES_OVERRIDE | 409 | close | closureMode is complainant_confirmation and no overrideReason | Wait for the complainant, or give an override reason. |
COMPLAINT_REOPEN_NOT_ALLOWED | 409 | Portal reopen | complainantCanReopen is off | Contact the school. |
COMPLAINT_REOPEN_WINDOW_EXPIRED | 409 | Portal reopen | Past reopenWindowDays from the current resolution | Contact the school. |
COMPLAINT_CANCEL_NOT_ALLOWED | 409 | Portal cancel | complainantCanCancel is off | Contact the school. |
COMPLAINT_DUPLICATE_TARGET_INVALID | 400 | markDuplicate | Target is itself, invisible, a draft, already a duplicate, or the source already has duplicates | Pick another target. |
COMPLAINT_DUE_DATE_INVALID | 400 | setDueDate | Not in the future or not after submission | Fix the date. |
COMPLAINT_NO_CHANGE | 409 | changePriority, changeCategory | The value is unchanged | Nothing to do. |
COMPLAINT_NOT_ANONYMOUS | 409 | revealIdentity | The complaint is not anonymous | Read the submitter from the detail. |
COMPLAINT_BULK_LIMIT_EXCEEDED | 400 | ComplaintBulkService | More than 100 items (up to the DTO's hard ceiling of 1000, above which VALIDATION_FAILED answers first) | Split the batch. |
COMPLAINT_EVENT_CURSOR_INVALID | 400 | ComplaintReadService.listEvents | before is not a visible event of this complaint | Restart paging. |
COMPLAINT_ATTACHMENT_NOT_FOUND | 404 | ComplaintAttachmentDownloadService | Not on this complaint, or internal without ComplaintInternalNotes_READ, or non-public on the portal | Reload. |
COMPLAINT_ATTACHMENT_TYPE_NOT_ALLOWED | 400 | Multer filter, ComplaintAttachmentService, constraint translation | Extension or MIME not allowed, sniff disagrees, or the structural check refuses the type | Use an allowed type. |
COMPLAINT_ATTACHMENT_TOO_LARGE | 413 | Multer error filter, ComplaintAttachmentService | Over 25 MB at multer, or over maxAttachmentSizeMb | Shrink the file. |
COMPLAINT_ATTACHMENT_LIMIT_REACHED | 409 | ComplaintAttachmentService | Per-message (maxAttachments) or lifetime (maxTotalAttachments) limit | Remove the need, or attach to another message. |
COMPLAINT_ATTACHMENT_EMPTY | 400 | ComplaintAttachmentService | Zero-byte file | Choose a real file. |
COMPLAINT_ATTACHMENT_REJECTED | 422 | complaint-attachment-scan.ts | The structural scan rejected the file | Replace the file. |
COMPLAINT_ATTACHMENT_SCAN_UNAVAILABLE | 503 | complaint-attachment-scan.ts | The scan threw or passed its 20-second timeout | Retry later. |
COMPLAINT_ATTACHMENT_STORAGE_UNAVAILABLE | 503 | ComplaintAttachmentService | Storage write failed | Retry later. |
COMPLAINT_ATTACHMENT_EVENT_INVALID | 400 | complaint-attachment-surface.ts | eventId is not an attachable event written by the caller in the last 30 minutes, or a portal upload without eventId on a non-draft | Attach within the window. |
COMPLAINT_ATTACHMENT_VISIBILITY_MISMATCH | 400 | complaint-attachment-surface.ts | visibility conflicts with the event's | Omit visibility. |
14. Observability
| Signal | Location | Purpose |
|---|---|---|
| Log | ComplaintScheduler.enqueueSweep (Logger.error) | Enqueue failure — the ONLY channel that failure has, since it is swallowed to protect later ticks. |
| Log | ComplaintQueueProcessor.process (Logger.error) | Unhandled job name — should never fire given the exhaustive Record. |
| Log | runSweepPass (Logger.warn, per candidate) | A single complaint's sweep effect failing does not stop the pass. |
| Log | ComplaintSweepService.run (Logger.log) | One structured line per sweep run: counts per pass plus total duration. |
| Redis marker | REDIS_KEYS.COMPLAINTS_SWEEP_LAST_SUCCESS | Operational health signal — a stale timestamp means the sweep is not completing. |
| Audit | complaint_events | The complaint's own permanent, append-only history — every state change and message. |
| Audit | Global ActivityAuditInterceptor | Every mutating complaints request, with redactEndpoint applied for complaint-audit modules. |
15. Testing and Validation
| Test Type | Files | Coverage |
|---|---|---|
| Unit | apps/api/src/utils/complaints/complaint-lifecycle.util.spec.ts, complaint-sla.util.spec.ts, complaint-text.util.spec.ts | The state machine's canPerform/nextStatus/isOverdue, SLA target precedence and due-date arithmetic, text/filename normalisation. |
| Integration | apps/api/src/modules/complaints/__tests__/complaint-config.integration.spec.ts | Categories, templates, priorities, SLA rules, settings, escalation rules, notification settings CRUD and their CHECK-constraint-backed validation. |
| Integration | complaint-register.integration.spec.ts | List, detail, event timeline, statistics, office recording, assignable-users picker, and the visibility predicate across submitter/subject/assignee/superadmin. |
| Integration | complaint-workflow.integration.spec.ts | Every workflow action's transition legality, version conflicts, and the full triage→handling→outcome path including reopen and its new SLA cycle. |
| Integration | complaint-portal.integration.spec.ts | Draft/submit/follow-up flows for each audience (student/guardian/teacher/staff/admin), audience resolution, and portal-side masking. |
| Integration | complaint-privacy.integration.spec.ts | Anonymity masking across every response projection, and the identity-reveal audit trail. |
| Integration | complaint-side-channels.integration.spec.ts | The activity-log, notification-history and upload-service closures listed in §11. |
| Integration | complaint-attachments.integration.spec.ts | Multer limits, sniffing, scanning, refusal cleanup, temp-file cleanup, download authorization, and the admin/portal placement split. |
| Integration | complaint-sweep.integration.spec.ts | All four sweep passes, SKIP LOCKED concurrency behaviour, and exactly-once firing via complaint_automation_log. |
Validation commands: pnpm --filter @skoolsewa/api test, pnpm --filter @skoolsewa/api test:structure, pnpm rules. All specs under __tests__/ connect to the real TEST_DATABASE_URL database — no mocked schema, Redis, or queue.
16. Mandatory Backend Deep-Dive Pack
16.1 Submodule Coverage Matrix
| Unit | Type | Owns | Depends On | Called By | Calls | State Touched | Failure Modes |
|---|---|---|---|---|---|---|---|
ComplaintsController | Controller | List, assigned-to-me, create (office record), detail, event timeline | ComplaintListService, ComplaintReadService, ComplaintOfficeRecordService, ComplaintAccessService | HTTP | Services above | Read-only except office-record create | 404 on invisible/nonexistent |
ComplaintStatisticsController/Service | Controller/service | Aggregate counts by status/category/priority/assignee | ComplaintAccessService.listVisibilityWhere | HTTP | Postgres aggregate queries | Read-only | — |
ComplaintAssignableUsersController | Controller | Picker data source | ComplaintPeopleService | HTTP | listAssignable | Read-only | Permission-less, allowlisted |
ComplaintReadService/ComplaintDetailMapper/-Query | Service/mapper | The one detail shape every command and the register share | ComplaintAccessService, masking utils | ComplaintsController, ComplaintWorkflowService's commands | Postgres reads | Read-only | — |
ComplaintListService/-ListQuery/-ResultUtil | Service | List query building, pagination, search (parseComplaintReference, escapeLikePattern, trigram search) | ComplaintAccessService | ComplaintsController | Postgres | Read-only | COMPLAINT_EVENT_CURSOR_INVALID on bad cursor |
ComplaintOfficeRecordService | Service | Office-recorded filing on behalf of a complainant | ComplaintPortalAudienceService-equivalent resolution, ComplaintSubmissionService.recordSubmitted | ComplaintsController.create | ComplaintSubmissionService, ComplaintHistoryService | Insert + submit in one call | COMPLAINT_COMPLAINANT_INVALID |
ComplaintPickerService | Service | Thin wrapper resolving eligible-assignee queries | ComplaintPeopleService | ComplaintAssignableUsersController | — | Read-only | — |
ComplaintTriageCommands/-HandlingCommands/-OutcomeCommands | Service (command groups) | Every state-changing action, grouped by lifecycle stage | ComplaintAccessService, ComplaintEffectsService, ComplaintPolicyService, ComplaintHistoryService, ComplaintNotifierService, lifecycle util | ComplaintWorkflowService | DB writes, history, notifications | complaints + child tables | See §13 |
ComplaintCommandBase | Shared base | Common locked-load/version-check/history-append scaffolding for the three command groups | ComplaintAccessService | The three command classes | — | — | — |
ComplaintWorkflowService | Service (façade) | One method per action, delegating to the right command group | The three command groups | ComplaintActionsController, ComplaintOutcomeActionsController, ComplaintBulkService | — | — | — |
ComplaintBulkController/Service | Controller/service | Per-id application of the SAME workflow methods, with a cap | ComplaintWorkflowService | HTTP | ComplaintWorkflowService methods | Same as single-item | COMPLAINT_BULK_LIMIT_EXCEEDED; per-id failures collected, not all-or-nothing |
ComplaintCategoriesService/Controller | Service/controller | Category CRUD, soft delete/restore, isSystem protection | complaint-category-questions.util.ts for question-shape validation | HTTP | Postgres | complaint_categories | Listed error codes above |
ComplaintTemplatesService/Controller | Service/controller | Template CRUD | Category existence | HTTP | Postgres | complaint_templates | — |
ComplaintPrioritiesService/Controller | Service/controller | Update the 4 fixed priority rows (labels/targets/alerting) | — | HTTP | Postgres | complaint_priority_settings | Version conflict |
ComplaintSlaRulesService/Controller | Service/controller | Category×priority override CRUD | Category existence | HTTP | Postgres | complaint_sla_rules | — |
ComplaintSettingsService/Controller | Service/controller | Singleton settings PATCH, per-kind reminder PATCH | — | HTTP | Postgres | complaint_settings, complaint_reminder_settings | — |
ComplaintEscalationRulesService/Controller | Service/controller | Rule CRUD, recipient-path validation | Category existence | HTTP | Postgres | complaint_escalation_rules | COMPLAINT_ESCALATION_RECIPIENT_INVALID |
ComplaintNotificationSettingsService/Controller | Service/controller | Per-event-key channel/enable PATCH | — | HTTP | Postgres | complaint_notification_settings | — |
ComplaintAttachmentService | Service | Persists the row after placement is resolved | Storage package | ComplaintAttachmentsController, ComplaintPortalAttachmentsController | Storage write, complaint_attachments insert | complaint_attachments | COMPLAINT_ATTACHMENT_STORAGE_UNAVAILABLE |
ComplaintAttachmentDownloadService | Service | Streams a stored file after a visibility+attachment-visibility check | Storage package | Admin/portal download endpoints | Storage read | Read-only | COMPLAINT_ATTACHMENT_NOT_FOUND |
complaint-attachment.multer.ts/-multer-error.filter.ts | Interceptor/filter | Upload transport limits and error translation to COMPLAINT_ATTACHMENT_* codes | — | Attachment controllers | — | Temp filesystem | — |
complaint-attachment-sniff.ts | Pure function | Magic-byte verification | — | Upload pipeline | — | — | COMPLAINT_ATTACHMENT_TYPE_NOT_ALLOWED |
complaint-attachment-scan.ts | Service | Structural scan through AssignmentScanWorkerPool, 20-second timeout | AssignmentScanWorkerPool | Upload pipeline | Worker pool | — | _REJECTED/_SCAN_UNAVAILABLE |
complaint-attachment-refusal-cleanup.ts | Function | Deletes already-persisted bytes on a later-stage refusal | Storage package | Upload pipeline catch blocks | Storage delete | — | Logged, never re-thrown over the original error |
complaint-attachment-temp-file-cleanup.interceptor.ts | Interceptor | Removes the local temp file unconditionally after the request | — | Every attachment controller | Filesystem | — | — |
complaint-attachment-surface.ts | Pure/service functions | Admin vs. portal placement and authorization split | ComplaintAccessService | Both attachment pipelines | — | — | See §7.6 |
ComplaintPortalController | Controller | Form options, list, create draft, detail, update draft, discard draft, submit | ComplaintPortalAudienceService, ComplaintPortalDraftService, ComplaintPortalQueryService, ComplaintSubmissionService | HTTP | — | complaints (draft lifecycle) | — |
ComplaintPortalActionsController/Service | Controller/service | Respond to information request, confirm resolution, reopen, cancel — the complainant's own follow-ups | ComplaintWorkflowService's underlying commands (called with a complainant capacity) | HTTP | Commands | complaints + children | See §13 |
ComplaintPortalAttachmentsController | Controller | Portal upload/download, delegating placement to complaint-attachment-surface.ts's portal functions | ComplaintAttachmentCoreModule | HTTP | Upload pipeline | complaint_attachments | See §7.6 |
ComplaintPortalAudienceService | Service | Resolves filer identity and eligible related students | students/guardians/staff/studentGuardian tables | Portal controllers | — | Read-only | COMPLAINT_AUDIENCE_UNRESOLVED |
ComplaintPortalProjectionService/-Util | Service/util | The complainant-facing response shape — the ONE place masking does NOT apply, since the complainant reads about themselves | complaint-response.dto.ts | Portal controllers | — | Read-only | — |
ComplaintPortalQueryService | Service | The complainant's own list/detail queries, scoped to submitterUserId = actor.id | — | Portal controllers | Postgres | Read-only | — |
ComplaintPortalDraftService | Service | Draft create/update/discard, draft-count limit | ComplaintPolicyService (attachment limits) | Portal controllers | ComplaintSubmissionService on submit | complaints draft rows | COMPLAINT_DRAFT_LIMIT_REACHED, COMPLAINT_PRIORITY_NOT_SELECTABLE |
ComplaintPortalIdentity (complaint-portal-identity.ts) | Pure helper | "Is this MY complaint" identity check reused across portal surfaces | — | Portal controllers/services | — | — | — |
ComplaintScheduler | Service (@Cron) | Enqueues the sweep | BullMQ | NestJS scheduler | Queue | None (documented outbox exemption) | Swallowed + logged |
ComplaintQueueProcessor | Service (@Processor) | The one worker | ComplaintSweepService | BullMQ | Sweep service | — | Throws on unknown job name |
ComplaintSweepService | Service | Orchestrates the four effect passes within one time budget, then the retention pass | Passes, RedisCacheService | Processor | Passes | complaint_automation_log, Redis marker | Logged per-pass |
complaint-sweep-overdue.pass.ts/-reminders.pass.ts/-escalation.pass.ts/-auto-close.pass.ts | Functions | The four sweep effects | complaint-sweep-shared.ts, complaint-sweep-anchors.ts, complaint-sla.util.ts | ComplaintSweepService | DB, ComplaintNotifierService, ComplaintHistoryService | complaints, complaint_automation_log, complaint_events | Per-candidate try/catch |
complaint-sweep-retention.pass.ts | Function | Deletes automation log rows that can never block a claim, once past retention | Lifecycle util (TERMINAL_STATUSES) | ComplaintSweepService | DB | complaint_automation_log (DELETE) | Counted as a sweep failure |
complaint-sweep-shared.ts | Functions | Generic keyset pass loop + exactly-once claiming | — | All four effect passes | — | complaint_automation_log | — |
complaint-sweep-anchors.ts | Functions | Shared anchor computation (unassignedAnchor, activeAssignmentAnchor) | complaint_assignments | Reminders/escalation passes | — | Read-only | — |
complaint-filing.rules.ts | Pure module | ComplaintFiler type and filing-time eligibility rules shared by portal and office-record paths | — | Audience/submission services | — | — | — |
complaint-errors.util.ts | Pure module | assertCanPerform and other shared error-construction helpers | Lifecycle util | Command groups, attachment surface | — | — | — |
complaint-config.util.ts/complaint-category-questions.util.ts | Pure modules | Shared config-service helpers (version bump, question-shape validation) | — | Config services | — | — | — |
16.2 UML and Architecture Diagram Pack
16.3 Code Flow Narrative
ComplaintSubmissionService.submitDraft (§7.1):
- Entry:
ComplaintPortalDraftService.submit(POST /:id/submit) or.createwithsubmit: true; the office path reaches the samefinalisethroughrecordSubmitted. - Command shape: complaint id, actor, optional expected-version.
- Auth: the actor must be the draft's own submitter (portal) or hold
Complaints_CREATE(office recording). - Normalisation: title/description/location run through
complaint-text.util.tsat draft-write time, not here. - Validation order, portal
submit: own row locked → version → audience resolved → named student re-authorised against the current audience → audience kind must equal the draft's (COMPLAINT_SUBMITTER_KIND_CHANGED) → per-submitter lock → daily limit. Thenfinalise: category and template usable → text normalised → anonymity rules, including no complainant-uploaded file outsidejpg,jpeg,png,webp,txt(COMPLAINT_ATTACHMENT_ANONYMOUS_TYPE_REFUSED) → incident date → template-required fields → answers → related people → attachment-required → guarded update (COMPLAINT_NOT_DRAFT). - Reads: category, template, priority settings, applicable SLA rule.
- Business decision:
resolveTargets(precedence template → SLA rule → category → priority) thencomputeAckDueAt/computeDueAt. - Writes:
nextval(complaint_reference_seq)(thecomplaints_assign_referencetrigger then issues the opaquereference),status='submitted',submittedAt,ackDueAt,dueAt,dueSource. - Transaction: one transaction spans the sequence allocation, the row update, and the history event.
- Cache: none.
- Notifications: triage (or the alert variant if the category's priority sets
alertOnSubmit), and the complainant ifsubmitted_complainantis enabled. - Response mapping:
ComplaintReadService's shared detail shape, throughsubmitterView/actorViewmasking. - Errors: see §13's submission-related rows.
- Logs/audit:
complaint_eventsrow (submitted);ActivityAuditInterceptorrecords the mutating request withredactEndpoint. - Tradeoff: allocating the reference at submission rather than draft creation means a draft never carries one. A rolled-back submit leaves a gap in the internal sequence, which nobody outside the database can see, because the public reference is random.
ComplaintTriageCommands.assign (§7.2):
- Entry:
ComplaintActionsController.assign(ComplaintAssignments_CREATE) orComplaintWorkflowService.assignfrom bulk. - Command shape: complaint id, target
assigneeUserId, optionalreason, expected version. - Auth: permission plus
ComplaintAccessService.resolveVisible(lock: true). - Validation order:
canPerform("assign", status, archived)→ version check → no active assignment →isEligibleAssignee. - Reads: current active assignment (if any), target user's eligibility.
- Business decision:
nextStatus("assign", from)→"assigned". - Writes: sets
acknowledgedAtand appendsacknowledgedwhen the complaint was never acknowledged, inserts the new activecomplaint_assignmentsrow, updatescomplaints.assignedToUserId/assignedAt/status/version. - Transaction: single transaction; the deferred constraint trigger validates
assigned_to_user_idmatches the sole active assignment at commit. - Cache: none.
- Notifications:
assigned_assigneeto the new assignee,assigned_complainantif enabled. - Response: shared detail shape.
- Errors:
COMPLAINT_ASSIGNEE_INELIGIBLE,COMPLAINT_ALREADY_ASSIGNED,COMPLAINT_VERSION_CONFLICT,COMPLAINT_INVALID_TRANSITION. - Logs/audit:
complaint_events(assigned), activity log. - Tradeoff: the assignment table is append-only history rather than a mutable "current assignee" column, so a complaint's whole handling chain survives reassignment — the parent's
assigned_to_user_idis a denormalised, trigger-verified pointer to it for query convenience.
Sweep escalation pass (runEscalationPass, §7.7):
- Entry:
ComplaintSweepService.run, third of four passes, with its own deadline share and its own try/catch, so a failure in another pass does not stop it. - Candidate source:
runSweepPass's keyset page over open complaints matching an active escalation rule's trigger and (category/priority filters). - Auth: none — system actor.
- Per-candidate transaction:
SELECT ... FOR NO KEY UPDATE SKIP LOCKEDon the one complaint row. - Re-check under lock: the anchor (from
complaint-sweep-anchors.ts) plusafterMinutesmust still be pastnow— a concurrent state change (e.g. acknowledgement) since the page was fetched must not fire a stale escalation. - Claim:
tryClaimFiringwith the rule's key, the complaint's current SLA cycle, and a sequence number for the escalation level. - Writes (only if claimed):
complaints.escalationLevel += 1,lastEscalatedAt,version += 1andlastActivityAt, so a client caching byversionsees the new level. - History:
escalatedevent, system actor. - Notifications:
ComplaintNotifierService.escalationRecipients— assignee's manager, triage, and the rule's explicit list. - Failure: caught per-candidate, logged, loop continues — one broken escalation rule does not stop the pass for every other complaint.
- Tradeoff: escalation level has no ceiling in the schema (only
>= 0); an unresolved complaint under a shortafterMinutesand many active rules can escalate repeatedly — accepted, since suppressing repeats is a rule-authoring concern (isActive), not a data-integrity one.
16.4 Data Layer Deep Dive
Full field tables are in §5.2. Index rationale:
| Index/Constraint | Columns | Type | Query/Invariant Supported | Tradeoff |
|---|---|---|---|---|
complaints_status_idx | status, last_activity_at desc, partial WHERE archived_at IS NULL | btree | The register's default "open complaints, most recently active first" list | Excludes archived rows from the index entirely — an archived-only query falls back to a sequential scan, acceptable since that view is rare. |
complaints_due_open_idx | due_at, partial on the 5 open statuses | btree | The overdue sweep pass's candidate scan | Partial index must be kept in sync with OPEN_STATUSES in the lifecycle util — a status added there without a migration update silently excludes it from the sweep. |
complaints_title_trgm/complaints_description_trgm | title/description | GIN gin_trgm_ops | ILIKE/trigram search from ComplaintListService | Write overhead on every title/description change; unbounded description length (≤10000 chars) makes the description index the larger of the two. |
complaint_assignments_one_active | complaint_id, partial WHERE ended_at IS NULL | unique btree | Enforces "at most one active assignment" at the database level, independent of the deferred trigger | Doubles as the fast "who handles this now" lookup. |
complaint_info_requests_one_open | complaint_id, partial WHERE responded_at IS NULL AND withdrawn_at IS NULL | unique btree | Enforces "at most one open request", backing COMPLAINT_INFORMATION_REQUEST_OPEN | — |
complaint_resolutions_one_current | complaint_id, partial WHERE superseded_at IS NULL | unique btree | Enforces "at most one current resolution" | — |
complaint_automation_log_once | complaint_id, kind, rule_key, cycle, sequence | unique btree | The sweep's exactly-once claim | Bounded by the sweep's retention pass (§7.7 step 8). |
complaints_submission_unique | submitter_user_id, submission_id, partial WHERE submission_id IS NOT NULL | unique btree | Idempotent draft creation | — |
16.5 Business Logic and Invariant Catalog
| Invariant | Enforced By | Why It Exists | Failure Error | Tests |
|---|---|---|---|---|
A move from status A to action X is legal only per ACTION_FROM | apps/api/src/utils/complaints/complaint-lifecycle.util.ts | Single source of truth for what the database's CHECKs cannot express (moves, not coherence) | COMPLAINT_INVALID_TRANSITION | complaint-lifecycle.util.spec.ts |
A handling action on assigned/reopened moves to in_progress ONLY when the caller is the current assignee | nextStatus, context.callerIsAssignee | A triage admin answering on a handler's behalf must not silently mark the handler as investigating | — (behavioural, no dedicated error) | complaint-lifecycle.util.spec.ts, complaint-workflow.integration.spec.ts |
assigned_to_user_id on complaints equals the sole active row in complaint_assignments | Deferred constraint trigger, complaints_assert_consistent | Prevents the parent's denormalised pointer drifting from its own history | SQLSTATE 23514 at commit | complaint-sweep.integration.spec.ts (SKIP LOCKED concurrency) |
waiting_for_information holds exactly when one request is open | Same deferred trigger | Status and child-row state cannot desynchronise even across a multi-statement transaction | 23514 | complaint-workflow.integration.spec.ts |
resolved/closed holds exactly when one resolution is current | Same deferred trigger | Same reasoning | 23514 | — |
| The filing is frozen once submitted | complaints_guard_update trigger | A complaint's account of what happened must not change after the fact; only handling state (status, assignment, resolution) may move | 23514 | complaint-register.integration.spec.ts |
| A submitted complaint can never be deleted | complaints_guard_delete trigger (refuses delete on anything but draft) | Permanence of the record | 23514 | — |
complaint_events is append-only | complaint_events_append_only trigger | The permanent history cannot be rewritten by any later bug or operator | 23514 | — |
| Anonymous complaints never name a related student | CHECK complaints_anonymous_has_no_related_student | An anonymous filing naming a specific child is not anonymous in practice | 23514 | complaint-privacy.integration.spec.ts |
| An admin submitter cannot file anonymously | CHECK complaints_admin_not_anonymous | An office-recorded, admin-authored complaint has no anonymity to protect | 23514 | — |
record_status/soft-delete never leaks a category still referenced | restrict FK from complaints/complaint_templates/complaint_sla_rules/complaint_escalation_rules to complaint_categories | A category cannot be hard-deleted while history depends on it | FK violation | complaint-config.integration.spec.ts |
| Every sweep effect fires at most once per (complaint, kind, rule, cycle, sequence) | complaint_automation_log unique constraint + ON CONFLICT DO NOTHING RETURNING claim pattern | A sweep tick that races a retry, or two workers momentarily overlapping, must not double-notify or double-escalate | Silent (losing transaction simply does not apply the effect) | complaint-sweep.integration.spec.ts |
| Masking applies to every admin-surface read regardless of the caller's permissions | shared/complaint-masking.util.ts, called from every response mapper | ComplaintIdentity_READ grants the ability to REVEAL through the audited endpoint, not silent visibility elsewhere | — (behavioural) | complaint-privacy.integration.spec.ts |
| The reopen window is measured from the CURRENT resolution, not the original submission | withinReopenWindow(resolvedAt, now, windowDays) | A complaint resolved, reopened, and resolved again gets a fresh window each time | COMPLAINT_REOPEN_WINDOW_EXPIRED | complaint-sla.util.spec.ts |
Automatic closure only applies to resolutions made under closureMode='automatic' | complaint_resolutions.closureMode captured at resolution time | Switching the school's setting must never mass-close the existing backlog | — | complaint-sweep.integration.spec.ts |
| The daily submission limit is per submitter, per school-local calendar day | ComplaintPolicyService.schoolToday/startOfSchoolDay | Consistent with the rest of the platform's school-timezone day boundary, not UTC midnight | COMPLAINT_SUBMISSION_LIMIT_REACHED | — |
16.6 Tradeoffs, Alternatives, and ADR Notes
| Decision | Context | Chosen Option | Alternatives | Why Chosen | Tradeoffs | Revisit Trigger |
|---|---|---|---|---|---|---|
| Mutable status column vs. derived-only status | complaints.status is a persisted enum column, not computed from the latest child row | Persisted, trigger-verified | Compute status on every read from complaint_assignments/complaint_info_requests/complaint_resolutions | The status column is what every index (complaints_status_idx, complaints_due_open_idx) and every list query filters on; deriving it on read would make the register's default list a join-heavy scan | Requires the three deferred constraint triggers to keep it honest, and any new code path writing complaints must remember the coherence rules | A sixth invariant needing cross-table coherence would be a signal to reconsider |
| Anonymity as read-time masking vs. absent data | Submitter identity is always stored | Always stored, masked on read except the audited reveal | Store no identity when anonymous | An anonymous complainant must still receive notifications and the school must be able to investigate serious allegations with authorization | Every new response DTO must remember to route through the masking utils; a mapper written without them leaks identity silently | A masking omission found in review is itself the trigger to add a lint/test gate |
| Configuration cached vs. read fresh | complaint_settings, priorities, reminder/notification settings | Read straight from Postgres on every call | Cache with CacheKeyUtil and invalidate on PATCH | These rows change rarely and are read by a handful of endpoints and the 5-minute sweep, not a hot request path; adding a cache-invalidation surface would be complexity with no measured benefit | Every read of these rows is a Postgres query | A stale-settings incident, or the config surface becoming a hot path, is the revisit trigger |
| Sweep as one worker at concurrency 1 vs. sharded workers | Four passes share one 50-second budget | Single worker, time-boxed, keyset-paged | Multiple concurrent workers partitioned by complaint id | The exactly-once claim (ON CONFLICT DO NOTHING) already makes concurrent workers safe, but a single worker keeps ordering (overdue → reminders → escalation → auto-close) simple to reason about and avoids two workers racing the SAME page | If the candidate volume ever exceeds what one worker clears in 50 seconds regularly, sharding becomes necessary | Sweep failures/fired counts trending toward the page ceiling every tick |
| Public reference: random vs. sequential | complaints.reference, complaint_reference_seq | Random CMP- + 8 Crockford base32 characters, issued at submission; the sequence stays internal | CMP-000001 from the sequence | A near-gapless visible sequence is an existence oracle: a reader who is the subject of a complaint hidden from them sees the missing number, and roughly when it was filed | A reference cannot be guessed from its neighbours, and cannot be sorted by to get filing order (sort by submittedAt instead) | — |
complaint_automation_log retention | Exactly-once bookkeeping | Purge only rows that can never block a claim again, and only after 90 days | Purge by age alone; keep everything | Every claim is keyed on the complaint's current reopen_count, so a row from an earlier cycle, a row on a closed/rejected/cancelled complaint (only a reopen leaves those, and it raises the counter), or a past day's update_notice can never collide again. Deleting by age alone would let a still-live row go and a reminder or escalation fire twice | Up to 90 days of dead rows are kept for investigation | Table size still growing, which would mean long-open complaints accumulating live rows |
16.7 Operational Runbook
| Operation | How to Inspect | Healthy State | Failure Signal | Recovery |
|---|---|---|---|---|
| Sweep cadence | REDIS_KEYS.COMPLAINTS_SWEEP_LAST_SUCCESS value; server log for [complaints-sweep] lines | Timestamp updates roughly every 5 minutes | Stale timestamp, or repeated [complaints] could not enqueue the sweep log lines | Check BullMQ/Redis connectivity; a stuck jobId bucket self-clears on the next 5-minute boundary. |
| Sweep failures | [complaints-sweep] <passName> failed for complaint <id> warn lines; failures field in the run's summary log | failures: 0 most runs | Repeated failures for the same complaint id | Inspect that complaint's row for a data anomaly (e.g. a manually corrupted due_at); the sweep will retry it automatically next tick since nothing was claimed. |
| Exactly-once claiming | Query complaint_automation_log for a complaint id | One row per (kind, ruleKey, cycle, sequence) | Duplicate notifications reported by a school | Should be impossible given the unique constraint; if seen, check for a second worker process outside ComplaintWorkersModule's intended single deployment. |
| Attachment pipeline | Storage bucket for the complaint's storage_key prefix; scan service logs | File present, complaint_attachments row matches | COMPLAINT_ATTACHMENT_STORAGE_UNAVAILABLE/_SCAN_UNAVAILABLE in client-facing errors | Check the storage/scanning dependency's own health; complaint-attachment-refusal-cleanup.ts already prevents orphaned bytes on a refusal. |
| Anonymity masking | complaint-privacy.integration.spec.ts run; manual GET as a ComplaintIdentity_READ holder on an anonymous complaint | Submitter fields null except through revealIdentity | Identity visible on a list/detail/history/attachment response without a reveal | Treat as a security incident; audit complaint_events for identity_viewed rows to confirm whether a reveal actually occurred. |
| Deferred constraint coherence | Any bulk data-fix script touching complaints/child tables | Commit succeeds | 23514 at COMMIT (not immediately on the offending statement — deferred triggers) | The failing statement's transaction is somewhere earlier in the same multi-statement transaction; review every write to the four covered tables within it. |
16.8 Backend Risk Register
| Risk | Area | Impact | Current Mitigation | Remaining Gap |
|---|---|---|---|---|
| Sweep candidate volume outgrowing the 50-second budget | complaint-sweep-shared.ts keyset paging | Overdue/reminder/escalation/auto-close effects delayed past their 5-minute cadence | Deadline-bounded paging leaves the remainder for the next tick rather than blocking | No alert on sustained backlog growth; only the failures/fired log counts are available today |
complaint_automation_log growth | Schema | Index maintenance cost grows with volume | The retention pass deletes rows that can no longer block a claim once they are 90 days old, 1000 per tick | Rows for a complaint that stays open in one cycle are live and kept however old |
A new masking-relevant field added to a response DTO without routing through complaint-masking.util.ts | shared/dto/complaint-response.dto.ts and its mappers | Silent identity leak on an anonymous complaint | Code review convention plus complaint-privacy.integration.spec.ts | No automated lint enforcing every submitter/actor field passes through the masking functions |
| Escalation rules with overlapping triggers on the same complaint | complaint_escalation_rules | Repeated escalation notifications if multiple active rules match | Each rule's own ruleKey/cycle/sequence keeps firings distinct per rule, so this is by design, not a bug | Operationally can surprise a school if rules are authored without realizing they overlap; no UI warning today |
| Portal audience resolution assumes exactly one applicable kind per active role | ComplaintPortalAudienceService.resolve | A user holding both a staff and a guardian-scoped role must switch active role to file in the other capacity | Matches the repo-wide activeRole-only rule | Not a defect, but worth naming: filing "as a parent" while the active role is staff requires an explicit role switch in the client |
17. Zero-Omission Backend Checklist
- Every file in the module directory is represented or explicitly marked non-runtime.
- Every controller, service, provider, processor, scheduler, helper, mapper, DTO, enum, and schema is documented.
- Every method with business behavior has a code-flow narrative (representative methods per stage — submission, assignment, escalation — narrated in full per §16.3; the remaining commands follow the same order documented in §6.2).
- Every table/collection/cache object/job payload has field-level detail.
- Every index, constraint, relation, and delete behavior has rationale.
- Every lifecycle/status transition has a state diagram and transition table.
- Every read/write/action/job flow has sequence and activity diagrams.
- Every business invariant is cataloged.
- Every cache key, invalidation path, queue job, realtime event, and external call is documented.
- Every architectural tradeoff is documented with alternatives and revisit triggers.
- Every operational failure mode has a runbook entry.
18. Backend Completion Checklist
- Module boundaries are documented.
- Every controller, service, DTO, schema file, job, cache key, and event is covered.
- Every database table/collection has a field table and relationship diagram.
- Every runtime flow has a diagram and branch notes.
- API, feature/flows, and TDD docs are linked.
- No claim is made without a source file or documented source reference.
See Also
- API doc:
/docs/developer/complaints/api - Features and flows doc:
/docs/developer/complaints/feature