Skoolsewa - School Management System Docs
Developer ResourcesComplaints

Complaints Backend Documentation

Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for Complaints.

Complaints Backend Documentation

1. Documentation Evidence

AreaFiles InspectedVerified Details
Module wiringapps/api/src/modules/complaints/*.module.ts, apps/api/src/modules/complaints/*/*.module.ts, apps/api/src/app.module.tsAggregate/leaf composition, worker module isolation, MOBILE_CHILDREN/ADMIN_MODULES split.
Controllersregister/, workflow/, config/, attachments/, portal/*.controller.tsRoute surface, permissions, NO_PERMISSION_ADMIN_HANDLERS entries.
Servicesshared/, register/, workflow/, config/, attachments/, portal/*.service.ts, *.commands.tsBusiness logic, transaction boundaries, side effects.
DTOsshared/dto/complaint-response.dto.ts, register/dto/, workflow/dto/, config/dto/, attachments/dto/, portal/dto/API contracts and validation.
Schemapackages/db/src/schema/school/complaints.ts (12 tables)Tables, enums, CHECK constraints, indexes.
Migrationspackages/db/src/migrations/0039_complaints.sql, 0040_complaints_integrity.sqlDDL, triggers, deferred constraints, seed data.
Utilsapps/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.
Jobspackages/jobs/src/index.ts (QueueName.COMPLAINTS, ComplaintJob, ComplaintSweepPayload)Queue contract.
Error codesapps/api/src/common/types/error-codes.ts (COMPLAINT_*)Every error code this module throws.
Permissionspackages/db/src/authorization/permission-catalog.ts (Complaint*, MyComplaints, SUPERADMIN_ONLY_MODULES, COMPLAINT_AUDIT_MODULES)Permission modules, superadmin-only gates.
Guard allowlistapps/api/src/common/authorization/role.guard.ts (NO_PERMISSION_ADMIN_HANDLERS)Handlers that resolve visibility themselves.
Side channelsapps/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.tsAnonymity leaks closed outside the module itself.
Testsapps/api/src/modules/complaints/__tests__/*.integration.spec.ts, apps/api/src/utils/complaints/*.spec.tsCoverage 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 by ComplaintsSharedModule and re-exported so every controller's RoleGuard can inject RoleService.
  • 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 own complaint_notification_settings table.
  • File storage and structural scanning infrastructure — packages/storage and AssignmentFilesModule's scanning services, reused rather than re-implemented (imported by ComplaintAttachmentCoreModule).
  • Academic-session, class, grade and section identity — packages/db/src/schema/school/academic-sessions.ts and classes.ts; complaints only snapshot the academic context at filing time via ComplaintPolicyService.academicContext, reusing AttendanceSharedModule's session/date resolution.
  • Student, guardian and staff identity — packages/db/src/schema/school/people.ts; complaints reference students.id/staff.id and resolve audience membership through those tables but never writes to them.
  • The activity audit log and notification-history screens — activity and notification/admin/history own those tables; this module only tells them which entries must be redacted.

Source of Truth

ConcernSource of TruthNotes
Complaint statecomplaints 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 transitionsapps/api/src/utils/complaints/complaint-lifecycle.util.tsPure; the database enforces coherence, this module enforces which MOVES are legal.
Visibility and per-action authorizationComplaintAccessService (shared/complaint-access.service.ts), reading the caller's ACTIVE role onlyNever trust a client-supplied capacity or visibility flag.
SLA targets and due datesComplaintPolicyService.resolveTargets + apps/api/src/utils/complaints/complaint-sla.util.tsPrecedence template → SLA rule → category → priority, computed server-side at submission and on every priority/category change.
IdentityAuth module's AuthUser.activeRolescopeKind (`self
Anonymity maskingshared/complaint-masking.util.ts, applied by every response mapperNever trust that a caller "already can't see it" — masking runs even for ComplaintIdentity_READ holders; only the reveal endpoint discloses.

3. Module Composition

ModuleTypePathControllersProvidersExportsResponsibility
ComplaintsModuleAggregateapps/api/src/modules/complaints/complaints.module.tsNoneNoneNoneComposes the four ADMIN leaves for app.module.ts. Declares no controller of its own.
ComplaintsSharedModuleShared/leaf.../shared/complaints-shared.module.tsNoneComplaintAccessService, ComplaintHistoryService, ComplaintNotifierService, ComplaintPeopleService, ComplaintPolicyService, ComplaintDraftService, ComplaintSubmissionService, ComplaintEffectsServiceRoleModule + all providersEverything every other leaf needs: visibility, history, notification routing, people/eligibility, policy/targets, drafting, submission, and workflow side-effect helpers. Imports RoleModule, NotificationModule, AttendanceSharedModule.
ComplaintRegisterModuleLeaf, in ADMIN_MODULES.../register/complaint-register.module.tsComplaintsController, ComplaintStatisticsController, ComplaintAssignableUsersControllerComplaintReadService, ComplaintListService, ComplaintOfficeRecordService, ComplaintStatisticsService, ComplaintPickerServiceComplaintReadServiceThe register: list, detail, event timeline, statistics, office-recorded filing, assignee picker. Exports ComplaintReadService so ComplaintWorkflowModule returns the identical detail shape after every command.
ComplaintWorkflowModuleLeaf, in ADMIN_MODULES.../workflow/complaint-workflow.module.tsComplaintActionsController, ComplaintOutcomeActionsController, ComplaintBulkControllerComplaintTriageCommands, ComplaintHandlingCommands, ComplaintOutcomeCommands, ComplaintWorkflowService, ComplaintBulkServiceNoneEvery state-changing action, single and bulk. Imports ComplaintRegisterModule for ComplaintReadService.
ComplaintAttachmentsModuleLeaf, in ADMIN_MODULES.../attachments/complaint-attachments.module.tsComplaintAttachmentsControllerNoneNoneThe handler-side evidence upload route and the admin download route. Imports ComplaintAttachmentCoreModule for the actual pipeline.
ComplaintAttachmentCoreModuleShared, controller-less.../attachments/complaint-attachment-core.module.tsNoneComplaintAttachmentService, ComplaintAttachmentDownloadServiceBoth providersThe 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).
ComplaintConfigModuleLeaf, in ADMIN_MODULES.../config/complaint-config.module.tsSeven controllers (categories, templates, priorities, SLA rules, settings, escalation rules, notification settings)Matching seven servicesNoneEvery configuration surface.
ComplaintPortalModuleLeaf, in MOBILE_CHILDREN only.../portal/complaint-portal.module.tsComplaintPortalController, ComplaintPortalAttachmentsController, ComplaintPortalActionsControllerComplaintPortalAudienceService, ComplaintPortalProjectionService, ComplaintPortalQueryService, ComplaintPortalDraftService, ComplaintPortalActionsServiceNoneThe complainant surface, mounted only at /api/mobile/complaints — never imported by ComplaintsModule.
ComplaintWorkersModuleComposed by app.module.ts outside the fast profile.../workers/complaint-workers.module.tsNoneComplaintScheduler, ComplaintQueueProcessor, ComplaintSweepServiceNoneThe 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:

FilePurposeKey ExportsNotes
shared/complaint-access.service.tsLoads/locks a complaint row, decides visibility and every per-action permissionComplaintAccessService, ACTION_PERMISSIONThe single place Complaints_READ, ComplaintAssignments_READ, submitter/subject exclusion are combined.
shared/complaint-masking.util.tsAnonymity masking for every response projectionsubmitterView, actorView, attachmentNameView, contactViewRuns unconditionally; the reveal endpoint is the only bypass.
utils/complaints/complaint-lifecycle.util.tsThe state machinecanPerform, nextStatus, isOverdue, availableActionsPure; no NestJS/DB/clock.
utils/complaints/complaint-sla.util.tsTarget precedence and due-date arithmeticresolveTargets, computeDueAt, reminderSequenceDue, withinReopenWindowPure; caller passes now.
attachments/complaint-attachment-surface.tsWhere an uploaded file lands, per surfaceresolveAdminAttachmentPlacement, resolvePortalAttachmentPlacementThe one place admin/portal placement and authorization differ; the upload pipeline itself is shared.
portal/complaint-portal-audience.service.tsResolves who is filing and whom they may nameComplaintPortalAudienceServiceIdentity-layer-only lookups (deleted_at filter alone).
workers/complaint-sweep-shared.tsGeneric keyset sweep loop with exactly-once claimingrunSweepPass, tryClaimFiringShared 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 data

5.2 Tables and Collections

complaint_priority_settings

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
prioritycomplaint_priority enum PKNo—PK—low/medium/high/urgent; declaration order is the sort order.
label, descriptiontextlabel No, description Yes—length CHECKs (40 / 500)—
ackTargetMinutes, resolutionTargetMinutesintegerNo—range 1–525600, ack <= resolution—Minutes.
alertOnSubmitbooleanNofalse——Sends complaint.urgent_submitted to triage instead of the ordinary alert.
versionintegerNo1——Optimistic concurrency for this row's PATCH.
updatedByUserIduuidYes—FK users.id set null—

complaint_categories

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
idserial PKNo—PK—Internal FK target.
publicIduuidNouuid7()unique—Wire id.
nametextNo—unique on lower(name) where not deleted; format CHECK ≤80—
isActive, isSystembooleanNotrue / falseis_system never paired with deleted_at IS NOT NULL—Seeded categories are isSystem: renameable, deactivatable, never hard- or soft-deleted.
applicableAudiencestext[]Noall threesubset of student/guardian/staff—A teacher submits staff.
defaultPriorityenumNomedium——
ackTargetMinutes, resolutionTargetMinutesintegerYes—range + ack<=resolution when both set—Level 2 of the SLA precedence.
requiresAttachment, allowAnonymousbooleanNofalse——
questionsjsonbNo[]array, ≤20 entries—Category-specific questions; snapshotted onto the complaint at submission.
sortOrderintegerNo00–10000—
versionintegerNo1——
deletedAttimestamptzYes———Soft delete; blocked while isSystem.
FKs into this table———restrictcomplaints, complaint_templates, complaint_sla_rules, complaint_escalation_rulesNever hard-deletable while referenced.

complaint_sla_rules

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
id/publicIdserial/uuidNo—PK / unique—
categoryIdintegerNo—restrict FK, unique with prioritycomplaint_categories.idOne override row per (category, priority) pair — level 2 in the precedence.
priorityenumNo———
ackTargetMinutes, resolutionTargetMinutesintegerNo—range + ack<=resolution—Hard-deletable — configuration, not history.

complaint_templates

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
id/publicIdserial/uuidNo—PK/unique—
nametextNo—unique on lower(name) where not deleted; ≤80—
categoryIdintegerNo—restrict FKcomplaint_categories.id
titleHint, descriptionGuidancetextYes—length CHECKs (200 / 2000)—
defaultPriorityenumYes———
requiredFieldstext[]No[]subset of incident_date/incident_location/related_student/related_staff/attachments—Level 1 of SLA precedence for ack/resolution targets.
ackTargetMinutes, resolutionTargetMinutesintegerYes—range + pairing—
isActive, sortOrder, version—Notrue/0/1sortOrder 0–10000—
deletedAttimestamptzYes———Soft delete.

complaint_settings (singleton, id=1)

ColumnTypeDefaultConstraintNotes
idsmallint PK1id = 1Singleton row.
anonymousEnabledbooleantrue—School-wide toggle.
complainantCanSetPrioritybooleanfalse—
closureModeenummanualmanual/automatic/complainant_confirmationApplies to NEW resolutions only — see §16.6.
autoCloseAfterHoursinteger1681–8760
complainantCanReopen, reopenWindowDays—true / 301–365 days
complainantCanCancelbooleantrue—
maxAttachmentSizeMbinteger101–25
maxAttachmentsinteger51–10Per draft/message.
maxTotalAttachmentsinteger201–100, >= maxAttachmentsAcross the complaint's whole life.
dailySubmissionLimitinteger101–100Per submitter per school-local day.
allowedExtensionstext[]10 defaultssubset of the 10 known extensions
versioninteger1—

complaint_reminder_settings

ColumnTypeDefaultConstraintNotes
kindtext PK—one of 7 known kindsunacknowledged, unassigned, pending_information, approaching_due, overdue, pending_closure, reopened.
isEnabledbooleanfalse—All 7 kinds seeded disabled.
afterHoursinteger—0–8760Hours after the anchor; for approaching_due, hours BEFORE due.
repeatHoursinteger—1–720
maxRemindersinteger31–20

complaint_notification_settings

ColumnTypeDefaultConstraintNotes
eventKeytext PK—one of 20 known keysSee §9 for the full list.
isEnabledbooleantrue—
channelstext[]—subset of in_app/push/email/sms, enabled ⇒ ≥1 channel

complaint_escalation_rules

ColumnTypeNullableDefaultConstraintRelationNotes
id/publicIdserial/uuidNo—PK/unique—
name, isActivetext/booleanNotruelength ≤80—
triggerenumNo—unacknowledged/unassigned/no_response/unresolved/awaiting_information—
afterMinutesintegerNo—1–525600—
categoryId, priorityint/enumYes—restrict FKcomplaint_categories.idNull = any.
notifyAssigneeManager, notifyTriagebooleanNotrue——Manager = staff.manager_id.
recipientUserIdsuuid[]No[]≤20 entries; row-level CHECK requires at least one recipient path—
deletedAttimestamptzYes———

complaints (aggregate root)

ColumnTypeNullableDefaultConstraintRelationNotes
iduuidv7 PKNogeneratedPK—
referenceNobigintYes—unique; IFF status='draft' is null; >0complaint_reference_seqAllocated only at submission. Internal submission order: never returned, never searchable.
referencetextYes—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.
statusenum, 11 valuesNodraft——See §5.3 and §16.5.
titletextNo—non-blank, no control chars, 3–200 chars—
descriptiontextNo""≤10000; ≥10 trimmed chars once past draft—
categoryId, templateIdintcat No, tmpl Yes—restrict FKcomplaint_categories/complaint_templates
priorityenumNo———
submitterUserIduuidNo—restrict FKusers.idAlways recorded, even when anonymous — masking is on read, not absence.
submitterKindenumNo—student/guardian/teacher/staff/admin—
recordedByUserIduuidYes—set null FKusers.idOffice-recorded filing; portal hides it from the recorder when it equals the submitter.
isAnonymousbooleanNofalse⇒ no related student; admin submitter cannot be anonymous—
relatedStudentId, relatedStaffIduuidYes—restrict FKstudents.id/staff.id
academicSessionId, classId, gradeName, sectionNameint/textYes—restrict FK (session/class); class↔grade↔section all-null-or-all-set; class needs session and related studentacademic_sessions/classesSnapshotted at filing; a later promotion/transfer never rewrites these.
incidentDate, incidentLocation, contactPreferencedate/textYes—length CHECKs—
answers, questionSnapshotjsonbNo{} / []object/array typeof—
assignedToUserId, assignedAtuuid/tsYes—paired; required while assigned/in_progress/waiting_for_informationusers.id restrict
submittedAt, acknowledgedAt, firstResponseAttimestamptzYes—submittedAt paired with non-draft; acknowledgedAt required past triage—
ackDueAt, dueAt, dueSourcets/ts/enumYes—dueAt↔dueSource paired; both required once submitted—dueSource names which precedence level supplied the RESOLUTION target.
slaPausedAt, slaPausedSecondsts/bigintYes/No—/0slaPausedAt iff waiting_for_information; seconds ≥0—Paused seconds accumulate for the CURRENT cycle only.
resolvedAt, closedAttimestamptzYes—resolved/closed require resolvedAt; closedAt iff closed—
rejectedAt, rejectionReasonts/textYes—paired iff rejected; reason 3–2000 chars—
duplicateOfComplaintIduuidYes—self-FK restrict, not self, implies rejectedcomplaints.id
cancelledAt, cancellationReasonts/textYes—paired iff cancelled; reason 3–2000 chars—
reopenCount, lastReopenedAtint/tsNo/Yes0reopened requires timestamp; counters ≥0—The SLA cycle number.
escalationLevel, lastEscalatedAtint/tsNo/Yes0≥0—
archivedAttimestamptzYes—only from a terminal status—
lastActivityAttimestamptzNonow()——Drives the open-complaints list ordering.
submissionIduuidYes—unique with submitterUserId where not null—Client idempotency key for draft creation.
versionintegerNo1——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)

ColumnTypeConstraintNotes
iduuidv7 PK—
complaintIduuid, restrict FKone active per complaint via partial unique index on ended_at IS NULL
assigneeUserIduuid, restrict FK—
assignedByUserIduuid, set null FK—
reasontext1–1000 chars
endedAt, endedByUserId, endReason—paired; endReason IN ('reassigned','unassigned'); endedAt >= assignedAtRow is never deleted, only ended.

complaint_info_requests

ColumnTypeConstraintNotes
iduuidv7 PK—
complaintIdrestrict FKone open per complaint via partial unique index (respondedAt IS NULL AND withdrawnAt IS NULL)
questiontext3–5000 chars
responseText, respondedAt, respondedByUserId—paired; withdrawn XOR responded; response ≥ request time
withdrawnAt, withdrawnReason—paired

complaint_resolutions

ColumnTypeConstraintNotes
iduuidv7 PK—
complaintIdrestrict FKone current per complaint via partial unique index (supersededAt IS NULL)
summary, actionTakentext3–5000 chars each
followUpDate, followUpNotesdate/textnotes ≤2000Notes internal, never shown to complainant.
closureModeenum, No—Captured AT resolution time — switching the school's mode never mass-closes the backlog.
supersededAt, supersededReason—pairedSet when the complaint is reopened; row never deleted.

complaint_events (append-only permanent history)

ColumnTypeConstraintNotes
idbigint identity PK—
publicIduuid, unique—
complaintIdcascade FK—The one cascade in the module — draft discard removes its (nonexistent) events trivially; a submitted complaint cannot be deleted anyway (complaints_guard_delete).
eventTypeenum, 25 values—See §16.5.
visibilityenum—public/internal.
actorUserId, actorCapacityuuid/enumsystem iff actorUserId IS NULLcomplainant/handler/administrator/system.
fromValue, toValue, reasontext≤200/≤200/≤2000
bodytext1–10000; required when internal_note/public_response
infoRequestId, resolutionIduuidrestrict FKOptional links to the triggering child row.
metadatajsonbobject typeof

Migration 0040's complaint_events_append_only trigger refuses every UPDATE and DELETE on this table.

complaint_attachments

ColumnTypeConstraintNotes
iduuidv7 PK—
complaintIdcascade FK—A discarded DRAFT takes its files' rows with it.
eventIdbigint, restrict FK—Null for a standalone/draft upload.
visibility, uploadedByUserId, uploaderCapacity—uploaderCapacity <> 'system'System never uploads.
storageKeytext, unique—
originalNametext1–255 chars, no control// chars, no backslash
mimeType, extension, sizeBytes, sha256—extension known; size 1–26214400 bytes; sha256 lowercase hex 64

complaint_automation_log

ColumnTypeConstraintNotes
idbigint identity PK—
complaintIdrestrict FK—
kind, ruleKey, cycle, sequencetext/text/int/intunique together; kind one of 5 known; counters ≥0Exactly-once bookkeeping — one row per firing, never updated.
firedAttimestamptz—

5.3 Relationship Diagram

6. Services and Responsibilities

6.1 ComplaintAccessService (shared/complaint-access.service.ts)

MethodCalled ByReadsWritesSide EffectsErrors
permissionsOf(actor)Every command and read pathRoleService.getPermissionsForRoleId——AUTH_ACTIVE_ROLE_REQUIRED if no active role.
loadRow(executor, id, {lock})Every command and access checkcomplaints, 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 commandas above——COMPLAINT_NOT_FOUND (visibility failure and nonexistence answer identically)
heldActions(access)Portal/admin "available actions" surfacesACTION_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)

GroupActions OwnedReadsWritesSide Effects
ComplaintTriageCommandsacknowledge, assign, reassign, unassign, start_investigationcomplaints, complaint_assignmentscomplaints (status, assignedToUserId/assignedAt, acknowledgedAt), inserts/ends complaint_assignments rowsHistory event, notification to assignee/complainant
ComplaintHandlingCommandsadd_response, add_note, request_information, withdraw_information_request, change_priority, change_category, set_due_dateas above + complaint_info_requestscomplaint_info_requests (open/withdraw), complaints (priority/category/due fields, SLA pause), events, one update_notice row per note day in complaint_automation_logNotification; a private recomputeTargets re-derives dueAt (unless manual) and, while unacknowledged, ackDueAt on a priority or category change
ComplaintOutcomeCommandsresolve, close, reopen, reject, mark_duplicate, cancel, escalate, archive, unarchive, reveal_identityas above + complaint_resolutionscomplaint_resolutions (insert/supersede), complaints (terminal fields), eventsNotification, 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)

MethodCalled ByReadsWritesSide EffectsErrors
submitDraft(executor, complaintId, filer, actorUserId)ComplaintPortalDraftService.create (with submit: true) and .submitThe draft row itself (re-reads title/description/category/template/priority/etc. from it), then delegates to the private finalisecomplaints (referenceNo from complaint_reference_seq, status, submittedAt, ackDueAt, dueAt, dueSource) via finaliseHistory 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 finaliseSame as finalise, plus draft insertionCOMPLAINT_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, recordSubmittedcomplaint_settings, category/template usability (complaint-filing.rules.ts), attachment count when requiredcomplaints 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 requestIdCOMPLAINT_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)

MethodPurpose
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, startOfSchoolDaySchool-timezone day boundaries for the daily submission limit, incident-date checks and statistics.
sessionForDate, academicContextResolves 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)

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

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

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

MethodPurpose
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, isLiveRecipient 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

StepCode PathBehaviorFailure Case
1ComplaintPortalController.formOptionsReturns categories/templates applicable to the resolved audience—
2ComplaintPortalAudienceService.resolveIdentity-layer lookup of the caller's own student/guardian/staff rowCOMPLAINT_AUDIENCE_UNRESOLVED
3ComplaintPortalDraftService.createDraftValidates 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
4ComplaintSubmissionService.submitDraftEnforces required fields/answers/attachment, daily submission limit, computes SLA targets and due datesCOMPLAINT_REQUIRED_FIELD_MISSING, COMPLAINT_ANSWERS_INVALID, COMPLAINT_ATTACHMENT_REQUIRED, COMPLAINT_SUBMISSION_LIMIT_REACHED, COMPLAINT_INCIDENT_DATE_IN_FUTURE
5ComplaintHistoryService.appendWrites the submitted event inside the same transaction—
6ComplaintNotifierService.notifyNotifies triage (and complainant, per settings); urgent-priority categories fire the alert variantBest-effort — never fails the submission

7.2 Handler acknowledges, assigns, investigates, resolves, closes

StepCode PathBehaviorFailure Case
1ComplaintTriageCommands.acknowledgeRequires submitted statusCOMPLAINT_INVALID_TRANSITION
2.assignRefuses when an active assignment exists; acknowledges first if never acknowledged; inserts the active complaint_assignments rowCOMPLAINT_ASSIGNEE_INELIGIBLE, COMPLAINT_ALREADY_ASSIGNED
3.start (start_investigation)Caller must be the current assigneeCOMPLAINT_NOT_CURRENT_ASSIGNEE
4ComplaintHandlingCommands.addResponse/.addNoteMoves assigned/reopened → in_progress only if caller is the current assignee, per nextStatus—
5ComplaintOutcomeCommands.resolveInserts a complaint_resolutions row capturing the CURRENT closureMode; status → resolvedCOMPLAINT_INVALID_TRANSITION
6.closeRequires resolved; sets closedAtCOMPLAINT_INVALID_TRANSITION

7.3 Waiting for information (SLA pause) and resume

StepCode PathBehaviorFailure Case
1ComplaintHandlingCommands.requestInformationFrom assigned/in_progress/reopened only; status → waiting_for_information, slaPausedAt setCOMPLAINT_INFORMATION_REQUEST_OPEN (only one open request per complaint)
2Complainant respondToInformationRequest (portal complaint-portal-actions.controller.ts)Records responseText/respondedAt; ComplaintEffectsService.clockResumeFields accumulates slaPausedSeconds; status → in_progressCOMPLAINT_INFORMATION_REQUEST_NOT_FOUND
3withdrawInformationRequest (handler side)Same clock-resume path, withdrawnAt/withdrawnReason instead of a response—

7.4 Reopen, reject, duplicate, cancel

StepCode PathBehaviorFailure Case
1ComplaintOutcomeCommands.reopenStaff-side; from resolved/closed/rejected/cancelled. ComplaintEffectsService.reopen supersedes the resolution and starts a new SLA cycleCOMPLAINT_INVALID_TRANSITION, COMPLAINT_VERSION_CONFLICT
2ComplaintPortalActionsService.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 / .markDuplicateFrom 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: nullCOMPLAINT_DUPLICATE_TARGET_INVALID
4.cancel / portal cancelWithdraws an open information request and stops the clock via ComplaintEffectsService.cancel; the assignment stays. Staff cancel notifies cancelled_complainant, a complainant cancel notifies cancelled_handlersCOMPLAINT_CANCEL_NOT_ALLOWED (portal only, when complainantCanCancel is off)

7.5 Identity reveal

StepCode PathBehaviorFailure Case
1ComplaintOutcomeCommands.revealIdentityRequires ComplaintIdentity_READ (superadmin-only module — see §11); does not change statusCOMPLAINT_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)

StepCode PathBehaviorFailure Case
1complaint-attachment.multer.tsA 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.runCOMPLAINT_ATTACHMENT_TOO_LARGE (413) and COMPLAINT_ATTACHMENT_TYPE_NOT_ALLOWED (400), shaped by complaint-attachment-multer-error.filter.ts
2adminAttachmentPrecheck / portalAttachmentPrecheckFast, unlocked visibility/ownership check before doing any file workCOMPLAINT_NOT_FOUND
3complaint-attachment-sniff.tsMagic-byte check against the claimed extensionCOMPLAINT_ATTACHMENT_TYPE_NOT_ALLOWED
4complaint-attachment-scan.tsA 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'sCOMPLAINT_ATTACHMENT_REJECTED, COMPLAINT_ATTACHMENT_SCAN_UNAVAILABLE
5resolveAdminAttachmentPlacement / resolvePortalAttachmentPlacementRuns 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 transactionCOMPLAINT_ATTACHMENT_EVENT_INVALID, COMPLAINT_ATTACHMENT_VISIBILITY_MISMATCH, COMPLAINT_ATTACHMENT_ANONYMOUS_TYPE_REFUSED, COMPLAINT_ATTACHMENT_LIMIT_REACHED
6ComplaintAttachmentService.runHashes the stored bytes (SHA-256), writes to storage, then inserts the row with extension, MIME, size and hashCOMPLAINT_ATTACHMENT_STORAGE_UNAVAILABLE
7complaint-attachment-temp-file-cleanup.interceptor.tsRemoves the local temp file regardless of outcome—
8complaint-attachment-refusal-cleanup.tsOn any refusal after bytes were written (sniff/scan/storage failure), removes what was already persisted—

7.7 The automated sweep (every 5 minutes)

StepCode PathBehaviorFailure Case
1ComplaintScheduler.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 ruleEnqueue failure is swallowed and logged; no accompanying DB write (documented cron exemption from the outbox rule)
2ComplaintQueueProcessor.processRecord<ComplaintJob, handler> — a job name with no handler throws rather than silently completingUnhandled job name throws
3runOverduePassFlags OPEN_STATUSES complaints past dueAt; fires the overdue event and overdue_assignee once per SLA cycle, to the live assignee and triage, de-duplicatedPer-candidate failure logged, loop continues
4runRemindersPassPer 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-duplicatedSame
5runEscalationPassWalks 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 lastActivityAtSame
6runAutoClosePassRuns 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 cutoffSame
7Every passClaims via tryClaimFiring (complaint_automation_log, unique on complaint+kind+ruleKey+cycle+sequence) — only the winning transaction applies the effectRace losers simply skip
8runRetentionPass (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 automationLogPurgedA failure counts as one sweep failure and is logged; nothing else is affected

8. Caching

Cache Key PatternBuilderValueTTLInvalidationCaller
REDIS_KEYS.COMPLAINTS_SWEEP_LAST_SUCCESSDirect REDIS_KEYS constant (not CacheKeyUtil — a single global marker, not a per-entity key)ISO timestamp string7 daysWritten only for a tick with zero failures, so a stale value means failing ticks as well as missing onesComplaintSweepService.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

QueueJobProducerProcessorPayloadRetry/BackoffIdempotency
QueueName.COMPLAINTSComplaintJob.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 replayjobId = 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 JwtAuthGuard and RoleGuard. Most routes declare @Permissions(...); four handlers are permission-less by design and resolve visibility themselves inside the handler, each requiring an entry in both NO_PERMISSION_ADMIN_HANDLERS (role.guard.ts) and its CI mirror (route-permissions.spec.ts):
    • ComplaintsController.findOne — visibility from ComplaintAccessService (Complaints_READ OR current-assignee ComplaintAssignments_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 by ComplaintHistory_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.downloadForAdmin resolves the owning complaint through ComplaintAccessService.resolveVisible (Complaints_READ, or ComplaintAssignments_READ as the current assignee; never the submitter or the subject; a missing active role is 403 AUTH_ACTIVE_ROLE_REQUIRED). The attachment must belong to that complaint, and an internal attachment also needs ComplaintInternalNotes_READ; otherwise 404 COMPLAINT_ATTACHMENT_NOT_FOUND. Upload stays gated by ComplaintAttachments_CREATE.
    • ComplaintAssignableUsersController.list — requires ANY of ComplaintAssignments_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: ComplaintIdentity and ComplaintAudit are in SUPERADMIN_ONLY_MODULES — a disclosure about a child or colleague cannot be taken back, so neither is part of the automatic admin grant; a school must grant either down to a named role explicitly.
  • Portal audience: ComplaintPortalAudienceService.resolve derives the filer's kind (student/guardian/teacher/staff/admin) from activeRole.scopeKind plus a live staff row lookup, following the repo-wide portal rule — every lookup here is identity-layer only (deleted_at filter alone), because a failure here is a 404 against the caller's own audience.
  • Row-scope authorization on the register: ComplaintAccessService.listVisibilityWhere builds the WHERE predicate directly — never through PeopleAccessService.scopeFor, whose {kind:"all"} would hand the entire register to any Students_READ holder (the repo-wide portal rule's warning applies verbatim here).
  • Anonymity: shared/complaint-masking.util.ts masks submitterView, actorView, attachmentNameView and contactView unconditionally on every admin-surface read, including for ComplaintIdentity_READ holders. The only bypass is the audited revealIdentity action.
  • Rate limits: complaint_settings.dailySubmissionLimit (default 10, school-local day via ComplaintPolicyService.schoolToday) is the module's own abuse control on submission volume, enforced by ComplaintDraftService.assertDailySubmissionLimit under 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.ts removes 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 make photo<RLO>fdp.exe display as photoexe.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, webp and txt (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), answering 422 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 includes application/msword and application/vnd.ms-excel for .doc and .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) redacts actor.endpoint via redactEndpoint before the activity log records it.
    • activity.service.ts:39-93 — activityModuleFilter returns an empty result set for any complaint module requested by a caller who is not superadmin and does not hold ComplaintAudit_READ.
    • notification-history.service.ts — unless the caller is superadmin or holds ComplaintAudit_READ, events of category complaint do 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. Holding ComplaintAudit_READ is therefore equivalent to identity access.
    • upload.service.ts:69-71 — an Upload_READ holder 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 CodeHTTP StatusThrown ByConditionClient Action
COMPLAINT_NOT_FOUND404ComplaintAccessService.notFound, loadOwnComplaintRow, attachment surface and download serviceMissing, a draft on the admin surface, invisible to the caller, or not the caller's own on the portal; all answer identicallyRefresh; do not retry.
COMPLAINT_VERSION_CONFLICT409assertVersion; ComplaintDraftService.insertDraftexpectedVersion is stale; or a submissionId insert conflicted with no own row to replayReload and re-apply.
COMPLAINT_INVALID_TRANSITION409assertCanPerform; withdrawInformationRequestAction not legal from the current status; the request is no longer openReload.
COMPLAINT_ARCHIVED409assertCanPerformAny action other than unarchive or reveal on an archived complaintUnarchive first.
COMPLAINT_NOT_ARCHIVED409assertCanPerformUnarchive on a complaint that is not archivedReload.
COMPLAINT_ARCHIVE_TOO_EARLY409ComplaintOutcomeCommands.archiveA closed complaint is still inside the complainant's reopen window while reopening is onArchive after reopenWindowDays.
COMPLAINT_CONFIG_VERSION_CONFLICT409assertConfigVersion in complaint-config.util.tsA config row's expectedVersion is staleReload and re-apply.
COMPLAINT_CATEGORY_NOT_FOUND404Config services, filing rules, changeCategoryUnknown or deleted categoryPick another.
COMPLAINT_CATEGORY_INACTIVE400Filing rules, template service, changeCategoryThe category is deactivatedPick another.
COMPLAINT_CATEGORY_NOT_APPLICABLE400usableCategory in complaint-filing.rules.tsNot offered to the filer's audiencePick another.
COMPLAINT_CATEGORY_NAME_ALREADY_EXISTS409Constraint translation (complaint_categories_name_unique)Case-insensitive duplicate among live categoriesRename.
COMPLAINT_CATEGORY_SYSTEM_PROTECTED409ComplaintCategoriesService.remove, constraint translationDeleting an isSystem categoryDeactivate instead.
COMPLAINT_CATEGORY_HAS_TEMPLATES409ComplaintCategoriesService.remove, constraint translationA live template still names the categoryDelete or move the templates.
COMPLAINT_CATEGORY_QUESTIONS_INVALID400complaint-category-questions.util.tsQuestion list shape, keys, types or options invalidFix the questions.
COMPLAINT_TEMPLATE_NOT_FOUND404Template service, filing rulesUnknown or deleted templatePick another.
COMPLAINT_TEMPLATE_INACTIVE400Filing rulesThe template is deactivatedPick another.
COMPLAINT_TEMPLATE_NAME_ALREADY_EXISTS409Constraint translation (complaint_templates_name_unique)Duplicate live template nameRename.
COMPLAINT_TEMPLATE_CATEGORY_MISMATCH400Filing rulesThe template belongs to another categoryPick a matching template.
COMPLAINT_SLA_RULE_NOT_FOUND404ComplaintSlaRulesServiceUnknown ruleReload.
COMPLAINT_SLA_RULE_ALREADY_EXISTS409Constraint translation (complaint_sla_rules_one_per_pair)A rule exists for that category and priorityEdit the existing rule.
COMPLAINT_ESCALATION_RULE_NOT_FOUND404ComplaintEscalationRulesServiceUnknown ruleReload.
COMPLAINT_ESCALATION_RECIPIENT_INVALID422Escalation rules service, ComplaintOutcomeCommands.escalateA 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 unassignedChoose other recipients.
COMPLAINT_ANONYMOUS_NOT_ALLOWED400ComplaintSubmissionService (finalise)Anonymity off for the school or category, filer is admin, or the complaint is office-recordedFile non-anonymously.
COMPLAINT_ANONYMOUS_STUDENT_NOT_ALLOWED400Filing rules, draft service, constraint translationAnonymous and naming a related studentDrop one.
COMPLAINT_PRIORITY_NOT_SELECTABLE400ComplaintDraftService.assertPriorityChoosable, from portal draft create and updateA 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 insteadLeave priority out.
COMPLAINT_ATTACHMENT_ANONYMOUS_TYPE_REFUSED422complaint-attachment-surface.ts, assertNoAnonymousUnsafeAttachmentsA 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 anonymousRemove the file, or file with your name.
COMPLAINT_SUBMITTER_KIND_CHANGED409ComplaintPortalDraftService.submitThe active role's audience differs from the one the draft was filed underSwitch back to that role.
COMPLAINT_RELATED_STUDENT_NOT_FOUND404Filing rules, ComplaintPortalAudienceService.authoriseRelatedStudentNo live student, or one the caller may not namePick an allowed student.
COMPLAINT_RELATED_STAFF_NOT_FOUND404Filing rulesNo live staff rowPick another.
COMPLAINT_REQUIRED_FIELD_MISSING400Filing rules, submissionA template-required field or a required question is emptyComplete the form.
COMPLAINT_ANSWERS_INVALID400assertAnswers in filing rulesAn answer does not fit the question snapshotFix the answers.
COMPLAINT_ATTACHMENT_REQUIRED400SubmissionThe category or template requires a file and none is attachedUpload to the draft first.
COMPLAINT_INCIDENT_DATE_IN_FUTURE400SubmissionincidentDate is after the school-local todayFix the date.
COMPLAINT_NOT_DRAFT409Submission, portal draft serviceEdit, discard or submit on a complaint that is not a draftReload.
COMPLAINT_DRAFT_LIMIT_REACHED409ComplaintDraftService.assertDraftLimit20 open drafts (COMPLAINT_MAX_OPEN_DRAFTS)Submit or discard one.
COMPLAINT_SUBMISSION_LIMIT_REACHED429ComplaintDraftService.assertDailySubmissionLimitdailySubmissionLimit reached for the school-local dayTry tomorrow.
COMPLAINT_AUDIENCE_UNRESOLVED403ComplaintPortalAudienceService.resolveA student or guardian role with no live profile rowContact the office.
COMPLAINT_COMPLAINANT_INVALID422ComplaintOfficeRecordServiceNo live profile of that kind, or the account is deleted, banned or cannot log in; one code for every caseCheck the person.
COMPLAINT_COMPLAINANT_IS_RECORDER422ComplaintOfficeRecordServiceThe named complainant's account is the recorder's own. Such a record would be hidden from its recorder in both the register and the portalFile it from My complaints.
COMPLAINT_ASSIGNEE_INELIGIBLE422ineligibleAssignee() from the triage commandsisEligibleAssignee returned false (the submitter counts only on a non-anonymous complaint); one code for every reasonChoose from the picker.
COMPLAINT_ALREADY_ASSIGNED409Triage commands, constraint translation (complaint_assignments_one_active)Assign while an assignment is activeUse reassign.
COMPLAINT_NOT_ASSIGNED409Triage and handling commandsReassign, unassign or request information with no current assigneeAssign first.
COMPLAINT_SAME_ASSIGNEE409reassignThe new assignee is the current onePick someone else.
COMPLAINT_NOT_CURRENT_ASSIGNEE403startThe caller is not the assigneeOnly the assignee starts.
COMPLAINT_INFORMATION_REQUEST_OPEN409requestInformation, constraint translation (complaint_info_requests_one_open)A request is already openWait or withdraw it.
COMPLAINT_INFORMATION_REQUEST_NOT_FOUND404withdrawInformationRequest, portal respondUnknown request; on the portal also an answered or withdrawn oneReload.
COMPLAINT_CLOSURE_REQUIRES_OVERRIDE409closeclosureMode is complainant_confirmation and no overrideReasonWait for the complainant, or give an override reason.
COMPLAINT_REOPEN_NOT_ALLOWED409Portal reopencomplainantCanReopen is offContact the school.
COMPLAINT_REOPEN_WINDOW_EXPIRED409Portal reopenPast reopenWindowDays from the current resolutionContact the school.
COMPLAINT_CANCEL_NOT_ALLOWED409Portal cancelcomplainantCanCancel is offContact the school.
COMPLAINT_DUPLICATE_TARGET_INVALID400markDuplicateTarget is itself, invisible, a draft, already a duplicate, or the source already has duplicatesPick another target.
COMPLAINT_DUE_DATE_INVALID400setDueDateNot in the future or not after submissionFix the date.
COMPLAINT_NO_CHANGE409changePriority, changeCategoryThe value is unchangedNothing to do.
COMPLAINT_NOT_ANONYMOUS409revealIdentityThe complaint is not anonymousRead the submitter from the detail.
COMPLAINT_BULK_LIMIT_EXCEEDED400ComplaintBulkServiceMore than 100 items (up to the DTO's hard ceiling of 1000, above which VALIDATION_FAILED answers first)Split the batch.
COMPLAINT_EVENT_CURSOR_INVALID400ComplaintReadService.listEventsbefore is not a visible event of this complaintRestart paging.
COMPLAINT_ATTACHMENT_NOT_FOUND404ComplaintAttachmentDownloadServiceNot on this complaint, or internal without ComplaintInternalNotes_READ, or non-public on the portalReload.
COMPLAINT_ATTACHMENT_TYPE_NOT_ALLOWED400Multer filter, ComplaintAttachmentService, constraint translationExtension or MIME not allowed, sniff disagrees, or the structural check refuses the typeUse an allowed type.
COMPLAINT_ATTACHMENT_TOO_LARGE413Multer error filter, ComplaintAttachmentServiceOver 25 MB at multer, or over maxAttachmentSizeMbShrink the file.
COMPLAINT_ATTACHMENT_LIMIT_REACHED409ComplaintAttachmentServicePer-message (maxAttachments) or lifetime (maxTotalAttachments) limitRemove the need, or attach to another message.
COMPLAINT_ATTACHMENT_EMPTY400ComplaintAttachmentServiceZero-byte fileChoose a real file.
COMPLAINT_ATTACHMENT_REJECTED422complaint-attachment-scan.tsThe structural scan rejected the fileReplace the file.
COMPLAINT_ATTACHMENT_SCAN_UNAVAILABLE503complaint-attachment-scan.tsThe scan threw or passed its 20-second timeoutRetry later.
COMPLAINT_ATTACHMENT_STORAGE_UNAVAILABLE503ComplaintAttachmentServiceStorage write failedRetry later.
COMPLAINT_ATTACHMENT_EVENT_INVALID400complaint-attachment-surface.tseventId is not an attachable event written by the caller in the last 30 minutes, or a portal upload without eventId on a non-draftAttach within the window.
COMPLAINT_ATTACHMENT_VISIBILITY_MISMATCH400complaint-attachment-surface.tsvisibility conflicts with the event'sOmit visibility.

14. Observability

SignalLocationPurpose
LogComplaintScheduler.enqueueSweep (Logger.error)Enqueue failure — the ONLY channel that failure has, since it is swallowed to protect later ticks.
LogComplaintQueueProcessor.process (Logger.error)Unhandled job name — should never fire given the exhaustive Record.
LogrunSweepPass (Logger.warn, per candidate)A single complaint's sweep effect failing does not stop the pass.
LogComplaintSweepService.run (Logger.log)One structured line per sweep run: counts per pass plus total duration.
Redis markerREDIS_KEYS.COMPLAINTS_SWEEP_LAST_SUCCESSOperational health signal — a stale timestamp means the sweep is not completing.
Auditcomplaint_eventsThe complaint's own permanent, append-only history — every state change and message.
AuditGlobal ActivityAuditInterceptorEvery mutating complaints request, with redactEndpoint applied for complaint-audit modules.

15. Testing and Validation

Test TypeFilesCoverage
Unitapps/api/src/utils/complaints/complaint-lifecycle.util.spec.ts, complaint-sla.util.spec.ts, complaint-text.util.spec.tsThe state machine's canPerform/nextStatus/isOverdue, SLA target precedence and due-date arithmetic, text/filename normalisation.
Integrationapps/api/src/modules/complaints/__tests__/complaint-config.integration.spec.tsCategories, templates, priorities, SLA rules, settings, escalation rules, notification settings CRUD and their CHECK-constraint-backed validation.
Integrationcomplaint-register.integration.spec.tsList, detail, event timeline, statistics, office recording, assignable-users picker, and the visibility predicate across submitter/subject/assignee/superadmin.
Integrationcomplaint-workflow.integration.spec.tsEvery workflow action's transition legality, version conflicts, and the full triage→handling→outcome path including reopen and its new SLA cycle.
Integrationcomplaint-portal.integration.spec.tsDraft/submit/follow-up flows for each audience (student/guardian/teacher/staff/admin), audience resolution, and portal-side masking.
Integrationcomplaint-privacy.integration.spec.tsAnonymity masking across every response projection, and the identity-reveal audit trail.
Integrationcomplaint-side-channels.integration.spec.tsThe activity-log, notification-history and upload-service closures listed in §11.
Integrationcomplaint-attachments.integration.spec.tsMulter limits, sniffing, scanning, refusal cleanup, temp-file cleanup, download authorization, and the admin/portal placement split.
Integrationcomplaint-sweep.integration.spec.tsAll 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

UnitTypeOwnsDepends OnCalled ByCallsState TouchedFailure Modes
ComplaintsControllerControllerList, assigned-to-me, create (office record), detail, event timelineComplaintListService, ComplaintReadService, ComplaintOfficeRecordService, ComplaintAccessServiceHTTPServices aboveRead-only except office-record create404 on invisible/nonexistent
ComplaintStatisticsController/ServiceController/serviceAggregate counts by status/category/priority/assigneeComplaintAccessService.listVisibilityWhereHTTPPostgres aggregate queriesRead-only—
ComplaintAssignableUsersControllerControllerPicker data sourceComplaintPeopleServiceHTTPlistAssignableRead-onlyPermission-less, allowlisted
ComplaintReadService/ComplaintDetailMapper/-QueryService/mapperThe one detail shape every command and the register shareComplaintAccessService, masking utilsComplaintsController, ComplaintWorkflowService's commandsPostgres readsRead-only—
ComplaintListService/-ListQuery/-ResultUtilServiceList query building, pagination, search (parseComplaintReference, escapeLikePattern, trigram search)ComplaintAccessServiceComplaintsControllerPostgresRead-onlyCOMPLAINT_EVENT_CURSOR_INVALID on bad cursor
ComplaintOfficeRecordServiceServiceOffice-recorded filing on behalf of a complainantComplaintPortalAudienceService-equivalent resolution, ComplaintSubmissionService.recordSubmittedComplaintsController.createComplaintSubmissionService, ComplaintHistoryServiceInsert + submit in one callCOMPLAINT_COMPLAINANT_INVALID
ComplaintPickerServiceServiceThin wrapper resolving eligible-assignee queriesComplaintPeopleServiceComplaintAssignableUsersController—Read-only—
ComplaintTriageCommands/-HandlingCommands/-OutcomeCommandsService (command groups)Every state-changing action, grouped by lifecycle stageComplaintAccessService, ComplaintEffectsService, ComplaintPolicyService, ComplaintHistoryService, ComplaintNotifierService, lifecycle utilComplaintWorkflowServiceDB writes, history, notificationscomplaints + child tablesSee §13
ComplaintCommandBaseShared baseCommon locked-load/version-check/history-append scaffolding for the three command groupsComplaintAccessServiceThe three command classes———
ComplaintWorkflowServiceService (façade)One method per action, delegating to the right command groupThe three command groupsComplaintActionsController, ComplaintOutcomeActionsController, ComplaintBulkService———
ComplaintBulkController/ServiceController/servicePer-id application of the SAME workflow methods, with a capComplaintWorkflowServiceHTTPComplaintWorkflowService methodsSame as single-itemCOMPLAINT_BULK_LIMIT_EXCEEDED; per-id failures collected, not all-or-nothing
ComplaintCategoriesService/ControllerService/controllerCategory CRUD, soft delete/restore, isSystem protectioncomplaint-category-questions.util.ts for question-shape validationHTTPPostgrescomplaint_categoriesListed error codes above
ComplaintTemplatesService/ControllerService/controllerTemplate CRUDCategory existenceHTTPPostgrescomplaint_templates—
ComplaintPrioritiesService/ControllerService/controllerUpdate the 4 fixed priority rows (labels/targets/alerting)—HTTPPostgrescomplaint_priority_settingsVersion conflict
ComplaintSlaRulesService/ControllerService/controllerCategory×priority override CRUDCategory existenceHTTPPostgrescomplaint_sla_rules—
ComplaintSettingsService/ControllerService/controllerSingleton settings PATCH, per-kind reminder PATCH—HTTPPostgrescomplaint_settings, complaint_reminder_settings—
ComplaintEscalationRulesService/ControllerService/controllerRule CRUD, recipient-path validationCategory existenceHTTPPostgrescomplaint_escalation_rulesCOMPLAINT_ESCALATION_RECIPIENT_INVALID
ComplaintNotificationSettingsService/ControllerService/controllerPer-event-key channel/enable PATCH—HTTPPostgrescomplaint_notification_settings—
ComplaintAttachmentServiceServicePersists the row after placement is resolvedStorage packageComplaintAttachmentsController, ComplaintPortalAttachmentsControllerStorage write, complaint_attachments insertcomplaint_attachmentsCOMPLAINT_ATTACHMENT_STORAGE_UNAVAILABLE
ComplaintAttachmentDownloadServiceServiceStreams a stored file after a visibility+attachment-visibility checkStorage packageAdmin/portal download endpointsStorage readRead-onlyCOMPLAINT_ATTACHMENT_NOT_FOUND
complaint-attachment.multer.ts/-multer-error.filter.tsInterceptor/filterUpload transport limits and error translation to COMPLAINT_ATTACHMENT_* codes—Attachment controllers—Temp filesystem—
complaint-attachment-sniff.tsPure functionMagic-byte verification—Upload pipeline——COMPLAINT_ATTACHMENT_TYPE_NOT_ALLOWED
complaint-attachment-scan.tsServiceStructural scan through AssignmentScanWorkerPool, 20-second timeoutAssignmentScanWorkerPoolUpload pipelineWorker pool—_REJECTED/_SCAN_UNAVAILABLE
complaint-attachment-refusal-cleanup.tsFunctionDeletes already-persisted bytes on a later-stage refusalStorage packageUpload pipeline catch blocksStorage delete—Logged, never re-thrown over the original error
complaint-attachment-temp-file-cleanup.interceptor.tsInterceptorRemoves the local temp file unconditionally after the request—Every attachment controllerFilesystem——
complaint-attachment-surface.tsPure/service functionsAdmin vs. portal placement and authorization splitComplaintAccessServiceBoth attachment pipelines——See §7.6
ComplaintPortalControllerControllerForm options, list, create draft, detail, update draft, discard draft, submitComplaintPortalAudienceService, ComplaintPortalDraftService, ComplaintPortalQueryService, ComplaintSubmissionServiceHTTP—complaints (draft lifecycle)—
ComplaintPortalActionsController/ServiceController/serviceRespond to information request, confirm resolution, reopen, cancel — the complainant's own follow-upsComplaintWorkflowService's underlying commands (called with a complainant capacity)HTTPCommandscomplaints + childrenSee §13
ComplaintPortalAttachmentsControllerControllerPortal upload/download, delegating placement to complaint-attachment-surface.ts's portal functionsComplaintAttachmentCoreModuleHTTPUpload pipelinecomplaint_attachmentsSee §7.6
ComplaintPortalAudienceServiceServiceResolves filer identity and eligible related studentsstudents/guardians/staff/studentGuardian tablesPortal controllers—Read-onlyCOMPLAINT_AUDIENCE_UNRESOLVED
ComplaintPortalProjectionService/-UtilService/utilThe complainant-facing response shape — the ONE place masking does NOT apply, since the complainant reads about themselvescomplaint-response.dto.tsPortal controllers—Read-only—
ComplaintPortalQueryServiceServiceThe complainant's own list/detail queries, scoped to submitterUserId = actor.id—Portal controllersPostgresRead-only—
ComplaintPortalDraftServiceServiceDraft create/update/discard, draft-count limitComplaintPolicyService (attachment limits)Portal controllersComplaintSubmissionService on submitcomplaints draft rowsCOMPLAINT_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———
ComplaintSchedulerService (@Cron)Enqueues the sweepBullMQNestJS schedulerQueueNone (documented outbox exemption)Swallowed + logged
ComplaintQueueProcessorService (@Processor)The one workerComplaintSweepServiceBullMQSweep service—Throws on unknown job name
ComplaintSweepServiceServiceOrchestrates the four effect passes within one time budget, then the retention passPasses, RedisCacheServiceProcessorPassescomplaint_automation_log, Redis markerLogged per-pass
complaint-sweep-overdue.pass.ts/-reminders.pass.ts/-escalation.pass.ts/-auto-close.pass.tsFunctionsThe four sweep effectscomplaint-sweep-shared.ts, complaint-sweep-anchors.ts, complaint-sla.util.tsComplaintSweepServiceDB, ComplaintNotifierService, ComplaintHistoryServicecomplaints, complaint_automation_log, complaint_eventsPer-candidate try/catch
complaint-sweep-retention.pass.tsFunctionDeletes automation log rows that can never block a claim, once past retentionLifecycle util (TERMINAL_STATUSES)ComplaintSweepServiceDBcomplaint_automation_log (DELETE)Counted as a sweep failure
complaint-sweep-shared.tsFunctionsGeneric keyset pass loop + exactly-once claiming—All four effect passes—complaint_automation_log—
complaint-sweep-anchors.tsFunctionsShared anchor computation (unassignedAnchor, activeAssignmentAnchor)complaint_assignmentsReminders/escalation passes—Read-only—
complaint-filing.rules.tsPure moduleComplaintFiler type and filing-time eligibility rules shared by portal and office-record paths—Audience/submission services———
complaint-errors.util.tsPure moduleassertCanPerform and other shared error-construction helpersLifecycle utilCommand groups, attachment surface———
complaint-config.util.ts/complaint-category-questions.util.tsPure modulesShared 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):

  1. Entry: ComplaintPortalDraftService.submit (POST /:id/submit) or .create with submit: true; the office path reaches the same finalise through recordSubmitted.
  2. Command shape: complaint id, actor, optional expected-version.
  3. Auth: the actor must be the draft's own submitter (portal) or hold Complaints_CREATE (office recording).
  4. Normalisation: title/description/location run through complaint-text.util.ts at draft-write time, not here.
  5. 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. Then finalise: category and template usable → text normalised → anonymity rules, including no complainant-uploaded file outside jpg, jpeg, png, webp, txt (COMPLAINT_ATTACHMENT_ANONYMOUS_TYPE_REFUSED) → incident date → template-required fields → answers → related people → attachment-required → guarded update (COMPLAINT_NOT_DRAFT).
  6. Reads: category, template, priority settings, applicable SLA rule.
  7. Business decision: resolveTargets (precedence template → SLA rule → category → priority) then computeAckDueAt/computeDueAt.
  8. Writes: nextval(complaint_reference_seq) (the complaints_assign_reference trigger then issues the opaque reference), status='submitted', submittedAt, ackDueAt, dueAt, dueSource.
  9. Transaction: one transaction spans the sequence allocation, the row update, and the history event.
  10. Cache: none.
  11. Notifications: triage (or the alert variant if the category's priority sets alertOnSubmit), and the complainant if submitted_complainant is enabled.
  12. Response mapping: ComplaintReadService's shared detail shape, through submitterView/actorView masking.
  13. Errors: see §13's submission-related rows.
  14. Logs/audit: complaint_events row (submitted); ActivityAuditInterceptor records the mutating request with redactEndpoint.
  15. 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):

  1. Entry: ComplaintActionsController.assign (ComplaintAssignments_CREATE) or ComplaintWorkflowService.assign from bulk.
  2. Command shape: complaint id, target assigneeUserId, optional reason, expected version.
  3. Auth: permission plus ComplaintAccessService.resolveVisible(lock: true).
  4. Validation order: canPerform("assign", status, archived) → version check → no active assignment → isEligibleAssignee.
  5. Reads: current active assignment (if any), target user's eligibility.
  6. Business decision: nextStatus("assign", from) → "assigned".
  7. Writes: sets acknowledgedAt and appends acknowledged when the complaint was never acknowledged, inserts the new active complaint_assignments row, updates complaints.assignedToUserId/assignedAt/status/version.
  8. Transaction: single transaction; the deferred constraint trigger validates assigned_to_user_id matches the sole active assignment at commit.
  9. Cache: none.
  10. Notifications: assigned_assignee to the new assignee, assigned_complainant if enabled.
  11. Response: shared detail shape.
  12. Errors: COMPLAINT_ASSIGNEE_INELIGIBLE, COMPLAINT_ALREADY_ASSIGNED, COMPLAINT_VERSION_CONFLICT, COMPLAINT_INVALID_TRANSITION.
  13. Logs/audit: complaint_events (assigned), activity log.
  14. 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_id is a denormalised, trigger-verified pointer to it for query convenience.

Sweep escalation pass (runEscalationPass, §7.7):

  1. 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.
  2. Candidate source: runSweepPass's keyset page over open complaints matching an active escalation rule's trigger and (category/priority filters).
  3. Auth: none — system actor.
  4. Per-candidate transaction: SELECT ... FOR NO KEY UPDATE SKIP LOCKED on the one complaint row.
  5. Re-check under lock: the anchor (from complaint-sweep-anchors.ts) plus afterMinutes must still be past now — a concurrent state change (e.g. acknowledgement) since the page was fetched must not fire a stale escalation.
  6. Claim: tryClaimFiring with the rule's key, the complaint's current SLA cycle, and a sequence number for the escalation level.
  7. Writes (only if claimed): complaints.escalationLevel += 1, lastEscalatedAt, version += 1 and lastActivityAt, so a client caching by version sees the new level.
  8. History: escalated event, system actor.
  9. Notifications: ComplaintNotifierService.escalationRecipients — assignee's manager, triage, and the rule's explicit list.
  10. Failure: caught per-candidate, logged, loop continues — one broken escalation rule does not stop the pass for every other complaint.
  11. Tradeoff: escalation level has no ceiling in the schema (only >= 0); an unresolved complaint under a short afterMinutes and 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/ConstraintColumnsTypeQuery/Invariant SupportedTradeoff
complaints_status_idxstatus, last_activity_at desc, partial WHERE archived_at IS NULLbtreeThe register's default "open complaints, most recently active first" listExcludes 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_idxdue_at, partial on the 5 open statusesbtreeThe overdue sweep pass's candidate scanPartial 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_trgmtitle/descriptionGIN gin_trgm_opsILIKE/trigram search from ComplaintListServiceWrite overhead on every title/description change; unbounded description length (≤10000 chars) makes the description index the larger of the two.
complaint_assignments_one_activecomplaint_id, partial WHERE ended_at IS NULLunique btreeEnforces "at most one active assignment" at the database level, independent of the deferred triggerDoubles as the fast "who handles this now" lookup.
complaint_info_requests_one_opencomplaint_id, partial WHERE responded_at IS NULL AND withdrawn_at IS NULLunique btreeEnforces "at most one open request", backing COMPLAINT_INFORMATION_REQUEST_OPEN—
complaint_resolutions_one_currentcomplaint_id, partial WHERE superseded_at IS NULLunique btreeEnforces "at most one current resolution"—
complaint_automation_log_oncecomplaint_id, kind, rule_key, cycle, sequenceunique btreeThe sweep's exactly-once claimBounded by the sweep's retention pass (§7.7 step 8).
complaints_submission_uniquesubmitter_user_id, submission_id, partial WHERE submission_id IS NOT NULLunique btreeIdempotent draft creation—

16.5 Business Logic and Invariant Catalog

InvariantEnforced ByWhy It ExistsFailure ErrorTests
A move from status A to action X is legal only per ACTION_FROMapps/api/src/utils/complaints/complaint-lifecycle.util.tsSingle source of truth for what the database's CHECKs cannot express (moves, not coherence)COMPLAINT_INVALID_TRANSITIONcomplaint-lifecycle.util.spec.ts
A handling action on assigned/reopened moves to in_progress ONLY when the caller is the current assigneenextStatus, context.callerIsAssigneeA 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_assignmentsDeferred constraint trigger, complaints_assert_consistentPrevents the parent's denormalised pointer drifting from its own historySQLSTATE 23514 at commitcomplaint-sweep.integration.spec.ts (SKIP LOCKED concurrency)
waiting_for_information holds exactly when one request is openSame deferred triggerStatus and child-row state cannot desynchronise even across a multi-statement transaction23514complaint-workflow.integration.spec.ts
resolved/closed holds exactly when one resolution is currentSame deferred triggerSame reasoning23514—
The filing is frozen once submittedcomplaints_guard_update triggerA complaint's account of what happened must not change after the fact; only handling state (status, assignment, resolution) may move23514complaint-register.integration.spec.ts
A submitted complaint can never be deletedcomplaints_guard_delete trigger (refuses delete on anything but draft)Permanence of the record23514—
complaint_events is append-onlycomplaint_events_append_only triggerThe permanent history cannot be rewritten by any later bug or operator23514—
Anonymous complaints never name a related studentCHECK complaints_anonymous_has_no_related_studentAn anonymous filing naming a specific child is not anonymous in practice23514complaint-privacy.integration.spec.ts
An admin submitter cannot file anonymouslyCHECK complaints_admin_not_anonymousAn office-recorded, admin-authored complaint has no anonymity to protect23514—
record_status/soft-delete never leaks a category still referencedrestrict FK from complaints/complaint_templates/complaint_sla_rules/complaint_escalation_rules to complaint_categoriesA category cannot be hard-deleted while history depends on itFK violationcomplaint-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 patternA sweep tick that races a retry, or two workers momentarily overlapping, must not double-notify or double-escalateSilent (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 permissionsshared/complaint-masking.util.ts, called from every response mapperComplaintIdentity_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 submissionwithinReopenWindow(resolvedAt, now, windowDays)A complaint resolved, reopened, and resolved again gets a fresh window each timeCOMPLAINT_REOPEN_WINDOW_EXPIREDcomplaint-sla.util.spec.ts
Automatic closure only applies to resolutions made under closureMode='automatic'complaint_resolutions.closureMode captured at resolution timeSwitching 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 dayComplaintPolicyService.schoolToday/startOfSchoolDayConsistent with the rest of the platform's school-timezone day boundary, not UTC midnightCOMPLAINT_SUBMISSION_LIMIT_REACHED—

16.6 Tradeoffs, Alternatives, and ADR Notes

DecisionContextChosen OptionAlternativesWhy ChosenTradeoffsRevisit Trigger
Mutable status column vs. derived-only statuscomplaints.status is a persisted enum column, not computed from the latest child rowPersisted, trigger-verifiedCompute status on every read from complaint_assignments/complaint_info_requests/complaint_resolutionsThe 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 scanRequires the three deferred constraint triggers to keep it honest, and any new code path writing complaints must remember the coherence rulesA sixth invariant needing cross-table coherence would be a signal to reconsider
Anonymity as read-time masking vs. absent dataSubmitter identity is always storedAlways stored, masked on read except the audited revealStore no identity when anonymousAn anonymous complainant must still receive notifications and the school must be able to investigate serious allegations with authorizationEvery new response DTO must remember to route through the masking utils; a mapper written without them leaks identity silentlyA masking omission found in review is itself the trigger to add a lint/test gate
Configuration cached vs. read freshcomplaint_settings, priorities, reminder/notification settingsRead straight from Postgres on every callCache with CacheKeyUtil and invalidate on PATCHThese 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 benefitEvery read of these rows is a Postgres queryA stale-settings incident, or the config surface becoming a hot path, is the revisit trigger
Sweep as one worker at concurrency 1 vs. sharded workersFour passes share one 50-second budgetSingle worker, time-boxed, keyset-pagedMultiple concurrent workers partitioned by complaint idThe 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 pageIf the candidate volume ever exceeds what one worker clears in 50 seconds regularly, sharding becomes necessarySweep failures/fired counts trending toward the page ceiling every tick
Public reference: random vs. sequentialcomplaints.reference, complaint_reference_seqRandom CMP- + 8 Crockford base32 characters, issued at submission; the sequence stays internalCMP-000001 from the sequenceA 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 filedA reference cannot be guessed from its neighbours, and cannot be sorted by to get filing order (sort by submittedAt instead)—
complaint_automation_log retentionExactly-once bookkeepingPurge only rows that can never block a claim again, and only after 90 daysPurge by age alone; keep everythingEvery 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 twiceUp to 90 days of dead rows are kept for investigationTable size still growing, which would mean long-open complaints accumulating live rows

16.7 Operational Runbook

OperationHow to InspectHealthy StateFailure SignalRecovery
Sweep cadenceREDIS_KEYS.COMPLAINTS_SWEEP_LAST_SUCCESS value; server log for [complaints-sweep] linesTimestamp updates roughly every 5 minutesStale timestamp, or repeated [complaints] could not enqueue the sweep log linesCheck 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 logfailures: 0 most runsRepeated failures for the same complaint idInspect 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 claimingQuery complaint_automation_log for a complaint idOne row per (kind, ruleKey, cycle, sequence)Duplicate notifications reported by a schoolShould be impossible given the unique constraint; if seen, check for a second worker process outside ComplaintWorkersModule's intended single deployment.
Attachment pipelineStorage bucket for the complaint's storage_key prefix; scan service logsFile present, complaint_attachments row matchesCOMPLAINT_ATTACHMENT_STORAGE_UNAVAILABLE/_SCAN_UNAVAILABLE in client-facing errorsCheck the storage/scanning dependency's own health; complaint-attachment-refusal-cleanup.ts already prevents orphaned bytes on a refusal.
Anonymity maskingcomplaint-privacy.integration.spec.ts run; manual GET as a ComplaintIdentity_READ holder on an anonymous complaintSubmitter fields null except through revealIdentityIdentity visible on a list/detail/history/attachment response without a revealTreat as a security incident; audit complaint_events for identity_viewed rows to confirm whether a reveal actually occurred.
Deferred constraint coherenceAny bulk data-fix script touching complaints/child tablesCommit succeeds23514 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

RiskAreaImpactCurrent MitigationRemaining Gap
Sweep candidate volume outgrowing the 50-second budgetcomplaint-sweep-shared.ts keyset pagingOverdue/reminder/escalation/auto-close effects delayed past their 5-minute cadenceDeadline-bounded paging leaves the remainder for the next tick rather than blockingNo alert on sustained backlog growth; only the failures/fired log counts are available today
complaint_automation_log growthSchemaIndex maintenance cost grows with volumeThe retention pass deletes rows that can no longer block a claim once they are 90 days old, 1000 per tickRows 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.tsshared/dto/complaint-response.dto.ts and its mappersSilent identity leak on an anonymous complaintCode review convention plus complaint-privacy.integration.spec.tsNo automated lint enforcing every submitter/actor field passes through the masking functions
Escalation rules with overlapping triggers on the same complaintcomplaint_escalation_rulesRepeated escalation notifications if multiple active rules matchEach rule's own ruleKey/cycle/sequence keeps firings distinct per rule, so this is by design, not a bugOperationally 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 roleComplaintPortalAudienceService.resolveA user holding both a staff and a guardian-scoped role must switch active role to file in the other capacityMatches the repo-wide activeRole-only ruleNot 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

On this page

Complaints Backend Documentation1. Documentation Evidence2. Backend Scope and BoundariesOwnsDoes Not OwnSource of Truth3. Module Composition4. File and Directory Map5. Data Model5.1 Schema Source5.2 Tables and Collectionscomplaint_priority_settingscomplaint_categoriescomplaint_sla_rulescomplaint_templatescomplaint_settings (singleton, id=1)complaint_reminder_settingscomplaint_notification_settingscomplaint_escalation_rulescomplaints (aggregate root)complaint_assignments (append history)complaint_info_requestscomplaint_resolutionscomplaint_events (append-only permanent history)complaint_attachmentscomplaint_automation_log5.3 Relationship Diagram6. Services and Responsibilities6.1 ComplaintAccessService (shared/complaint-access.service.ts)6.2 Complaint lifecycle commands (workflow/complaint-triage.commands.ts, -handling.commands.ts, -outcome.commands.ts)6.3 ComplaintSubmissionService (shared/complaint-submission.service.ts)6.4 ComplaintPolicyService (shared/complaint-policy.service.ts)6.5 ComplaintEffectsService (shared/complaint-effects.service.ts)6.6 ComplaintHistoryService (shared/complaint-history.service.ts)6.7 ComplaintNotifierService (shared/complaint-notifier.service.ts)6.8 ComplaintPeopleService (shared/complaint-people.service.ts)6.9 ComplaintDraftService (shared/complaint-draft.service.ts) and ComplaintPortalDraftService (portal/complaint-portal-draft.service.ts)7. Runtime Flows7.1 Complainant files and submits a complaint7.2 Handler acknowledges, assigns, investigates, resolves, closes7.3 Waiting for information (SLA pause) and resume7.4 Reopen, reject, duplicate, cancel7.5 Identity reveal7.6 Attachment upload (shared pipeline, surface-specific placement)7.7 The automated sweep (every 5 minutes)8. Caching9. BullMQ, Schedulers, and Async Work10. Realtime and Events11. Security, Auth, and Abuse Controls13. Error Handling14. Observability15. Testing and Validation16. Mandatory Backend Deep-Dive Pack16.1 Submodule Coverage Matrix16.2 UML and Architecture Diagram Pack16.3 Code Flow Narrative16.4 Data Layer Deep Dive16.5 Business Logic and Invariant Catalog16.6 Tradeoffs, Alternatives, and ADR Notes16.7 Operational Runbook16.8 Backend Risk Register17. Zero-Omission Backend Checklist18. Backend Completion ChecklistSee Also