Skoolsewa - School Management System Docs
Developer ResourcesComplaints

Complaints Features and Flows

Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for Complaints.

Complaints Features and Flows

1. Documentation Evidence

Source TypeFiles or DocsWhat Was Extracted
APISibling API doc, register/complaints.controller.ts, workflow/complaint-actions.controller.ts, workflow/complaint-outcome-actions.controller.ts, workflow/complaint-bulk.controller.ts, attachments/complaint-attachments.controller.ts, config/*.controller.ts, portal/complaint-portal*.controller.tsRoute surface, actors, permissions
BackendSibling backend doc, the services and schema it documentsBusiness behavior, side effects, lifecycle
Lifecycleapps/api/src/utils/complaints/complaint-lifecycle.util.tsThe state machine: actions, source statuses, next status, overdue rule
Schemapackages/db/src/schema/school/complaints.ts, packages/db/src/migrations/0040_complaints_integrity.sqlConstraints, settings, seeded categories and reminder kinds
Authorizationpackages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.tsActor permissions

2. Feature Summary

FieldValue
ModuleComplaints
SubmoduleMyComplaints (portal), ComplaintAssignments, ComplaintTriage, ComplaintResponses, ComplaintInternalNotes, ComplaintInformationRequests, ComplaintAttachments, ComplaintResolution, ComplaintClosure, ComplaintReopen, ComplaintRejection, ComplaintCancellation, ComplaintEscalation, ComplaintIdentity, ComplaintHistory, ComplaintStatistics, ComplaintCategories, ComplaintTemplates, ComplaintPriorities, ComplaintSettings, ComplaintNotificationSettings
Primary user valueA pupil, parent, teacher, or staff member files a grievance — optionally anonymously — and the office acknowledges, assigns, investigates, and resolves it against SLA targets, while the complainant can follow up, answer questions, and confirm or reopen the outcome.
ActorsStudent, guardian, teacher, staff (complainant, via the portal), office/handler admin (Complaints_*), assignee/handler (ComplaintAssignments_READ), the staff member named as the subject of a complaint, superadmin, the complaints worker (system)
Main entry points/api/mobile/complaints (portal filing and follow-up), /api/complaints admin register and workflow actions, /api/complaint-bulk/*, /api/complaint-statistics, /api/complaint-assignable-users, /api/complaint-categories, /api/complaint-templates, /api/complaint-priorities, /api/complaint-sla-rules, /api/complaint-escalation-rules, /api/complaint-settings, /api/complaint-notification-settings, the 5-minute complaints sweep
Main outputsPersisted complaints and their event timeline, resolutions, information requests, attachments, escalation/reminder notifications, complaint_automation_log firing records, statistics
Related docsAPI, backend

3. Actor Matrix

ActorCan DoCannot DoAuth RequirementNotes
Student / guardian / teacher / staff (MyComplaints_*)Draft, submit, list, and read their own complaints; respond to an information request; confirm a resolution; reopen (within the school's window) or cancel their own complaintSee anyone else's complaint; see or act on a complaint they themselves recorded as office staff (recordedByUserId is them)JWT (any authenticated identity holding the code)The portal's identity layer is submitter_user_id = actor AND recorded_by_user_id IS NULL OR <> actor — a recorder can never confirm/cancel/reopen on the complainant's behalf as if it were their own
Handler (ComplaintAssignments_READ, current assignee)See and act on complaints assigned to them; start investigation, respond, add notes/attachments, request informationSee complaints not assigned to them (without Complaints_READ); act if they are the complaint's own submitter or subjectAdmin JWT + ComplaintAssignments_READ as the current assigneestart_investigation and the assignee-triggered status flip on response/note/attachment require the caller to BE the assignee, not merely hold the permission
Office / triage administrator (Complaints_READ + action codes)See every non-draft complaint not their own and not about them; acknowledge, assign, reassign, unassign, resolve, close, reject, cancel, escalate, archive/unarchive, manage categories/templates/priorities/SLA/escalation rules/settings; record a complaint on someone's behalfSee a complaint they submitted or that names them as the subject staff member; reveal an anonymous identity without ComplaintIdentity_READAdmin JWT + Complaints_READ plus the per-action codeThe submitter and the named subject are excluded from admin visibility even when they hold Complaints_READ
Identity auditor (ComplaintIdentity_READ)Reveal an anonymous complainant's identity via the audited POST .../identity-revealReveal a complaint that is not anonymous (409 COMPLAINT_NOT_ANONYMOUS) or one they cannot see; reveal is allowed while archivedAdmin JWT + ComplaintIdentity_READEvery reveal writes an identity_viewed history row; masking still applies to every other reader
SuperadminSees and can act on every complaint regardless of submitter/subject exclusion—is_superadmin — cannot be scopedThe one exception to the submitter/subject exclusion, because superadmin cannot be scoped
Complaints worker (system)Fire reminders, overdue notices, escalations, and auto-close resolved complaints past the configured windowAct outside its own sweep candidates; fire twice for the same anchor/cycleInternal (BullMQ, cron)Exactly-once per (kind, ruleKey, cycle, sequence) via complaint_automation_log

4. Capability Matrix

CapabilitySurfaceActorRoute/TriggerState ReadState WrittenLinked API Section
File options / audiencePortalComplainantGET /api/mobile/complaints/form-optionscategories, templates, priorities—API §form-options
Search a student/staff to namePortalComplainantGET /api/mobile/complaints/student-options, /staff-optionsstudents/staff—API
Start a draft or submit directlyPortalComplainantPOST /api/mobile/complaintscategories, limitscomplaints draft or submittedAPI
List / read my complaintsPortalComplainantGET /api/mobile/complaints, /:idcomplaints (own)—API
Edit / discard a draftPortalComplainantPATCH/DELETE /api/mobile/complaints/:iddraft rowcomplaints, attachments cascadeAPI
Submit a draftPortalComplainantPOST /api/mobile/complaints/:id/submitdraft rowstatus, ackDueAt/dueAt, eventAPI
Answer / confirm / reopen / cancelPortalComplainant.../information-requests/:id/respond, /confirm-resolution, /reopen, /cancelown rowstatus, resolution, event, notificationAPI
Upload / download own attachmentPortalComplainantPOST /api/mobile/complaints/:id/attachments, GET .../attachments/:attachmentIdown rowcomplaint_attachmentsAPI
Record a complaint on behalfAdminOfficePOST /api/complaints (office record path)complainant profilecomplaints submitted, never a draftAPI
List / read the registerAdminOffice/handlerGET /api/complaints, /:idvisibility predicate—API
Acknowledge / assign / reassign / unassignAdminOfficePOST /api/complaints/:id/{acknowledge,assign,reassign,unassign}row + permissionstatus, assignedToUserIdAPI
Start investigationAdminAssigneePOST /api/complaints/:id/startrow, isAssigneestatus → in_progressAPI
Respond / note / attachAdminHandler/officePOST /api/complaints/:id/{responses,notes}, attachment uploadrowevent, possibly statusAPI
Request / withdraw informationAdminOffice/handlerPOST /api/complaints/:id/information-requests, .../withdrawrowstatus, slaPausedAt, request rowAPI
Change priority / category / due dateAdminOfficePOST /api/complaints/:id/{priority,category,due-date}rowfields, unchanged statusAPI
Resolve / close / reopen / reject / duplicate / cancel / escalateAdminOfficePOST /api/complaints/:id/{resolve,close,reopen,reject,mark-duplicate,cancel,escalate}rowstatus, resolution, eventAPI
Archive / unarchiveAdminOfficePOST /api/complaints/:id/{archive,unarchive}row (terminal)archivedAtAPI
Reveal identityAdminIdentity auditorPOST /api/complaints/:id/identity-revealrowaudited history row onlyAPI
Bulk operationsAdminOfficePOST /api/complaint-bulk/{acknowledge,assign,priority,category,close,reject,cancel,archive}many rows (at most 100)same effects, each item in its own transactionAPI
Manage categories/templates/priorities/SLA/escalation rulesAdminOffice/api/complaint-{categories,templates,priorities,sla-rules,escalation-rules}*config tablesCRUDAPI
Manage settings and reminder settingsAdminOffice/api/complaint-settings*complaint_settings, complaint_reminder_settingsversion-guarded updateAPI
StatisticsAdminOfficeGET /api/complaint-statisticsaggregates—API
Pick an assignee or escalation recipientAdminOfficeGET /api/complaint-assignable-userslive handlers—API
Manage notification settingsAdminOffice/api/complaint-notification-settings*complaint_notification_settingsversion-guarded updateAPI
Reminders / overdue / escalation / auto-close sweepsWorkerSystemcron, 5 mincomplaints, rules, settingshistory, notification, complaint_automation_logBackend

5. User-Facing Flows

5.1 A pupil files an anonymous complaint and the school investigates it

Summary

A pupil drafts a complaint about bullying, marks it anonymous, and submits it. The office acknowledges it, assigns a handler, and the handler investigates and resolves it — never learning who filed it unless an identity auditor performs an audited reveal.

Preconditions

  • JWT holding MyComplaints_CREATE.
  • A live category exists and allows the pupil's audience; if anonymous, the category must allow anonymity (allowAnonymous).
  • The daily submission limit (dailySubmissionLimit, default 10) is not exhausted, and the caller is under the open-draft limit.

Main Flow

StepActor/SystemActionResultSource
1PupilPOST /api/mobile/complaints with isAnonymous: true, submit: trueDraft row created then immediately submitted in one transactionComplaintPortalDraftService.create
2BackendResolves audience, authorises no related student (anonymous cannot name one), computes SLA targetsackDueAt/dueAt set, status submittedComplaintSubmissionService.submitDraft
3OfficePOST .../acknowledgeStatus → acknowledgedcomplaint-actions.controller.ts
4OfficePOST .../assign to a handlerStatus → assignedsame
5HandlerPOST .../startStatus → in_progress (only because the caller is the assignee)complaint-lifecycle.util.ts
6HandlerPOST .../responsesPublic response recorded on the timeline, masked to the complainant's identityComplaintMaskingUtil
7Office/handlerPOST .../resolveStatus → resolved, resolution row writtenworkflow commands
8PupilPOST /api/mobile/complaints/:id/confirm-resolutionStatus → closedComplaintPortalActionsService.confirmResolution

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Anonymous + related studentisAnonymous: true with relatedStudentId setRejectedCOMPLAINT_ANONYMOUS_STUDENT_NOT_ALLOWED
Category disallows anonymityallowAnonymous: false on the chosen categoryCategory still usable non-anonymously; anonymity is validated against the category, not silently droppedvalidation on submit
Duplicate submitSame submissionId sent twiceSecond call finds the row already created before any limit is checked, replayed: trueidempotent create
Anonymous with a document attachedThe pupil attached a .pdf or .docx, then files anonymouslyRefused: an anonymous complaint accepts only jpg, jpeg, png, webp and txt from the complainant, because document properties carry the author's name422 COMPLAINT_ATTACHMENT_ANONYMOUS_TYPE_REFUSED
Handler answers without startingOffice (not the assignee) adds a response on an assigned rowStatus stays assigned — only the assignee's own action starts investigationnextStatus callerIsAssignee guard
Non-assignee tries to startAny other handlerRefusedCOMPLAINT_INVALID_TRANSITION (from assertCanPerform)
Identity revealedIdentity auditor calls identity-revealAudited identity_viewed history row written; masking continues for everyone elseComplaintOutcomeActionsController.revealIdentity

5.2 A guardian's complaint is put on hold for more information, then resumed

Summary

A handler needs more detail from the complainant before proceeding. The SLA clock pauses while the question is open and resumes once the complainant answers or the school withdraws the request.

Preconditions

  • Complaint is assigned, in_progress, or reopened.
  • Actor holds ComplaintInformationRequests_CREATE.

Main Flow

StepActor/SystemActionResultSource
1HandlerPOST .../information-requestsStatus → waiting_for_information, slaPausedAt recordedcomplaint-handling.commands.ts
2GuardianPOST /mobile/complaints/:id/information-requests/:requestId/respondStatus → in_progress, pausedSeconds accumulated via effects.clockResumeFieldsComplaintPortalActionsService.respondToInformationRequest

Branches and Edge Cases

BranchConditionBehaviorError/Result
School withdraws insteadPOST .../information-requests/:requestId/withdrawStatus returns to in_progress, clock resumes the same waywithdraw_information_request
Complainant answers a withdrawn/already-answered requestrespondedAt or withdrawnAt already setRefusedCOMPLAINT_INFORMATION_REQUEST_NOT_FOUND
Reminder while waitingpending_information reminder kind, if enabledComplainant notified they still owe an answercomplaint-sweep-reminders.pass.ts
Overdue while waitingwaiting_for_information is not in OPEN_STATUSESNever flagged overdue — the clock is pausedisOverdue

5.3 A resolved complaint is confirmed, reopened, or auto-closes

Summary

Once resolved, the complaint's fate depends on the school's closureMode: the complainant confirms it, the school closes it manually, or it auto-closes after a configured number of hours. The complainant may also reopen it within a window if they are unsatisfied.

Preconditions

  • Complaint is resolved.
  • For complainant reopen: complainantCanReopen is true and now is within reopenWindowDays of the current resolution.

Main Flow

StepActor/SystemActionResultSource
1aComplainantPOST .../confirm-resolutionStatus → closed, closureKind: complainantComplaintPortalActionsService.confirmResolution
1bOfficePOST .../closeStatus → closed, closureKind: manual (workflow-side)complaint-outcome.commands.ts
1cWorkerclosureMode = automatic, autoCloseAfterHours elapsed since resolvedAt, resolution's own closureMode snapshot also automaticStatus → closed, complainant notifiedcomplaint-sweep-auto-close.pass.ts
2Complainant or staffPOST .../reopen (staff) or /mobile/.../reopen (complainant)Status → reopenedcomplaint-lifecycle.util.ts reopen/reopen_by_complainant

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Complainant reopen outside windownow - resolvedAt > reopenWindowDaysRefusedCOMPLAINT_REOPEN_WINDOW_EXPIRED
Complainant reopen disabledcomplainantCanReopen: falseRefusedCOMPLAINT_REOPEN_NOT_ALLOWED
Staff reopens a rejected/cancelled complaintreopen (staff-only action) from rejected/cancelledAllowed — undoes a mistaken rejection/cancellationACTION_FROM.reopen
Complainant reopens a rejected/cancelled complaintreopen_by_complainant from rejected/cancelledRefused — the complainant only reopens an outcome the school deliveredACTION_FROM.reopen_by_complainant (only resolved/closed)
Auto-close after a mode switchSchool was manual when resolved, later switched to automaticThat resolution's own snapshot is still manual — never retroactively auto-closedcomplaint-sweep-auto-close.pass.ts double gate
Complainant cancelscomplainantCanCancel: falseRefusedCOMPLAINT_CANCEL_NOT_ALLOWED

6. Admin Flows

6.1 Record a complaint on behalf of someone

POST /api/complaints resolves the named complainant by their profile id (students.id/guardians.id/staff.id, never users.id), requires the profile's user to be live (not deleted, not banned, canLogin), and refuses with the single error COMPLAINT_COMPLAINANT_INVALID for every failure kind — a distinct answer would let a caller probe which profile ids exist. When no complainant is named, the recording admin becomes the submitter themselves (submitterKind: "admin"); nobody complained in person, so the recorder reads and handles it in the register like any other handler. Naming yourself as the complainant is refused with COMPLAINT_COMPLAINANT_IS_RECORDER — your own complaint is filed from My complaints. A recorded complaint is never a draft and never anonymous. Every submitted complaint gets a random reference such as CMP-7K3QX9MB, so a missing number in the register can never reveal a complaint hidden from the reader.

6.2 List / read the register

GET /api/complaints requires Complaints_READ and applies ComplaintAccessService.listVisibilityWhere: never a draft, never the caller's own submission, never a complaint naming the caller as its subject staff member (a superadmin is exempt from the last two). GET /api/complaints/assigned-to-me requires ComplaintAssignments_READ and adds assigned_to_user_id = caller. Every row carries schoolTimezone, the school's IANA zone, and the panel renders every instant in it.

6.3 Triage and handling

Acknowledge, assign, reassign, unassign, and start-investigation each carry their own permission code (ACTION_PERMISSION in complaint-access.service.ts). start_investigation additionally requires the caller to be the current assignee — holding ComplaintAssignments_READ alone is not enough.

6.4 Outcomes

Resolve, close, reopen, reject, mark-duplicate, cancel, and escalate each write a complaint_events history row and, where relevant, notify the complainant or the handler chain. Archive/unarchive apply only to TERMINAL_STATUSES (closed, rejected, cancelled); every other action refuses on an archived complaint except unarchive and reveal_identity.

6.5 Bulk operations

complaint-bulk.controller.ts runs the same workflow commands as a batch, one complaint at a time, so a single row's refusal (wrong status, missing permission) does not abort the rest of the batch.

6.6 Category, template, priority, SLA rule, and escalation rule management

Categories and templates support list, get, create, update, soft delete and restore. Escalation rules support list, create, update, soft delete and restore. SLA rules support list, create, update and a hard delete. The four priority rows support list and update only. The 18 seeded system categories (isSystem: true) refuse deletion; deactivation (isActive: false) is always available instead. A category with a live template cannot be deleted. Every update, delete and restore carries expectedVersion.

6.7 Settings

GET/PATCH /api/complaint-settings is a single-row (id = 1), version-guarded configuration covering anonymity, closure mode, reopen/cancel policy, attachment caps, and the daily submission limit. PATCH /api/complaint-settings/reminders/:kind updates one of the seven reminder kinds independently, each with its own version.

7. Lifecycle and State Transitions

Source of truth: apps/api/src/utils/complaints/complaint-lifecycle.util.ts. ACTION_FROM lists every action's legal source statuses; nextStatus gives the resulting status (null means unchanged). archived is orthogonal to status and checked separately: only unarchive and reveal_identity are permitted on an archived complaint, and archive itself is legal only from a terminal status and refuses if already archived.

EntityFromEvent/ActionToGuard ConditionSide Effects
complaints(new)update_draft / submitdraft → submitteddraft onlyackDueAt/dueAt computed on submit
complaintssubmittedacknowledgeacknowledgedComplaintTriage_UPDATEhistory event
complaintssubmitted/acknowledged/reopenedassignassignedComplaintAssignments_CREATEassignedToUserId set
complaintsassigned/in_progress/waiting_for_information/reopenedreassignassigned only if from reopened, else unchangedComplaintAssignments_UPDATEnew assignee
complaintsassigned/reopenedunassignacknowledged only if from assigned, else unchangedComplaintAssignments_DELETEassignedToUserId cleared
complaintsassigned/reopenedstart_investigationin_progresscaller is assignee—
complaintsACTIVE_STATUSES (+resolved for note/attachment)add_response/add_note/add_attachmentin_progress only from assigned/reopened AND caller is assignee, else unchangedper-action permissionevent, possibly status flip
complaintsassigned/in_progress/reopenedrequest_informationwaiting_for_informationComplaintInformationRequests_CREATEslaPausedAt set
complaintswaiting_for_informationwithdraw_information_request / respond_to_information_requestin_progressdelete code / complainantclock resumes, pausedSeconds accumulated
complaintsACTIVE_STATUSESchange_priority/change_category/set_due_dateunchangedper-action permissionfield updated
complaintsacknowledged/assigned/in_progress/waiting_for_information/reopenedresolveresolvedComplaintResolution_UPDATEresolution row written
complaintsresolvedclose / confirm_resolutionclosedoffice / complainant respectivelyclosedAt set
complaintsresolved/closed/rejected/cancelledreopen (staff)reopenedComplaintReopen_UPDATEstaff may undo a rejection/cancellation
complaintsresolved/closed onlyreopen_by_complainantreopenedwithin reopen window, complainantCanReopencomplainant cannot reopen a rejection/cancellation
complaintsACTIVE_STATUSESreject/mark_duplicaterejectedComplaintRejection_UPDATEevent; from waiting_for_information the open request is withdrawn and the clock resumes; a duplicate's default reason is Marked as a duplicate of another complaint.
complaintsACTIVE_STATUSEScancelcancelledcomplainant (complainantCanCancel) or officeevent
complaintsACTIVE_STATUSESescalateunchangedComplaintEscalation_UPDATE or system sweepescalationLevel +1, notification
complaintsTERMINAL_STATUSESarchivearchived (status unchanged)not already archivedarchivedAt set
complaintsarchivedunarchivenot archivedmust currently be archivedarchivedAt cleared
complaintsany submitted statusreveal_identityunchangedComplaintIdentity_READ; allowed even while archivedaudited identity_viewed history row

Diagram (statuses only; archived is an orthogonal flag on any terminal status):

9. Data and Side Effects by Flow

FlowDB WritesCache EffectsJobsRealtimeAnalyticsNotifications
Draft / submitcomplaints (draft writes no event); on submit complaint_events————On submit: submitted_complainant, submitted_triage (urgent variant when the priority alerts on submit)
Acknowledge / assign / reassign / unassign / startcomplaints, complaint_assignments, complaint_events————acknowledged_complainant, assigned_assignee, assigned_complainant (seeded off), reassigned_previous_assignee; start sends none
Response / note / attachmentcomplaint_events, complaint_attachments, complaint_automation_log (note notices)————response_added_complainant; investigation_update_complainant for the first note of a school day (seeded off); attachments send none
Information request / respond / withdrawcomplaint_info_requests, complaints (status, slaPausedAt)————assignee/triage or complainant notified
Resolve / close / reopen / reject / cancelcomplaint_resolutions, complaints, complaint_events————complainant and/or handler chain notified
Escalate (manual or sweep)complaints.escalationLevel, version, lastActivityAt, complaint_events, complaint_automation_log————manager/triage/named recipients
Reminders sweepcomplaint_automation_log————staff reminders to the live assignee and triage, de-duplicated; complainant reminders to the complainant
Overdue sweepcomplaint_events, complaint_automation_log————live assignee and triage, de-duplicated
Auto-close sweepcomplaints (status), complaint_events, complaint_automation_log————complainant
Automation log retention (every sweep)complaint_automation_log (DELETE of rows over 90 days old that can never block a claim: an earlier cycle, a closed/rejected/cancelled complaint, or a past day's update notice; at most 1000 per tick)—————
Identity revealcomplaint_events (identity_viewed)—————

10. Error and Recovery Flows

ScenarioTriggerUser/System ExperienceRecoverySource
Concurrent editsTwo writers act on the same complaintSecond write's version no longer matches409 version conflict, reload and reapplyassertVersion
Invalid transitionAction attempted from a status not in ACTION_FROM[action]RefusedCOMPLAINT_INVALID_TRANSITIONcomplaint-errors.util.ts
Archived complaintAny action except unarchive/reveal_identity on an archived rowRefusedarchived-state errorcanPerform
Sweep candidate failsAn exception during one candidate's transaction in a sweep passLogged, loop continues to the next candidateCaught per-candidate, not per-passrunSweepPass
Priority chosen when not allowedA complainant sends priority while the school sets itDraft save refusedCOMPLAINT_PRIORITY_NOT_SELECTABLE; the filing form hides the field and drops a resumed draft's priorityComplaintDraftService.assertPriorityChoosable
Duplicate sweep firingTwo sweep ticks race on the same anchor/cycleOnly one wins the ON CONFLICT DO NOTHING RETURNING id claimThe loser's logId is null, no duplicate notificationtryClaimFiring
Complainant reopen window expirednow - resolvedAt > reopenWindowDaysRefusedCOMPLAINT_REOPEN_WINDOW_EXPIREDComplaintPortalActionsService.reopen
Complaint not visibleCaller lacks Complaints_READ/assignment, or is the complainant (the submitter of anything but an office record with nobody named) or the subject404, not 403 — avoids an existence oracle over children's grievancesComplaintAccessService.notFoundsame
Own complaint not found (portal)Any id the caller did not submit, or one the caller recorded as office staff404 COMPLAINT_NOT_FOUNDsame reasoning as the admin surfaceloadOwnComplaintRow

11. Diagrams Required Per Module

Covered above: the actor/capability tables (§3-4), the module flow and sequence diagrams (§5), the lifecycle state diagram (§7), the data side-effect table (§9), and the error/recovery table (§10).

12. Mandatory Feature and Flow Deep-Dive Pack

12.1 Feature Inventory With Minor Behaviors

FeatureMinor BehaviorActorTriggerUser/System ResultBackend Side EffectSource
DraftResubmitted submissionIdComplainantPOST with the same submissionIdNo duplicate rowreplayed: true returnedComplaintDraftService.insertDraft
DraftDraft limit reachedComplainantToo many open draftsRefuseddraft-limit errorComplaintDraftService.assertDraftLimit
SubmitDaily submission limitComplainantdailySubmissionLimit (default 10) already used todayRefuseddaily-limit errorassertDailySubmissionLimit
SubmitAnonymous + related studentComplainantisAnonymous: true with a named studentRefusedCOMPLAINT_ANONYMOUS_STUDENT_NOT_ALLOWEDcomplaint-filing.rules.ts
SubmitRelated student not the caller's own (student audience)StudentStudent caller names a different studentRefusedCOMPLAINT_RELATED_STUDENT_NOT_FOUNDauthoriseRelatedStudent
SubmitRelated student not the guardian's childGuardianGuardian names a non-childRefusedCOMPLAINT_RELATED_STUDENT_NOT_FOUNDsame
SubmitTeacher/staff without Students_READ names a studentTeacher or staffStaff/teacher callerRefusedCOMPLAINT_RELATED_STUDENT_NOT_FOUNDsame
AnswersUnknown answer keyComplainantAnswer not among the category's questionsRefusedCOMPLAINT_ANSWERS_INVALIDassertAnswers
AnswersRequired question blank at submitComplainantRequired question left emptyRefusedCOMPLAINT_REQUIRED_FIELD_MISSINGsame
HandlingNon-assignee respondsOffice or handlerOffice/handler not the assignee, row assigned/reopenedResponse recorded but status does NOT flip to in_progressno status changenextStatus
SubmitRole switched since the draft was savedComplainantDraft filed as guardian, submitted under a staff roleRefusedCOMPLAINT_SUBMITTER_KIND_CHANGEDComplaintPortalDraftService.submit
SubmitNamed student no longer authorisedComplainantA guardian-child link was removed after the draft was savedRefused at submitCOMPLAINT_RELATED_STUDENT_NOT_FOUNDauthoriseRelatedStudent re-run at submit
AnonymityDocument type on an anonymous complaintComplainantUpload, submit, or a draft saved as anonymous with a file other than jpg, jpeg, png, webp or txtRefusedCOMPLAINT_ATTACHMENT_ANONYMOUS_TYPE_REFUSEDcomplaint-attachment-anonymity.ts
AnonymityAssigning the anonymous submitterOfficeTriage picks the person who filed an anonymous complaintAllowed, so no refusal singles out the author; the assignee still cannot see it because visibility excludes the submitterno errorisEligibleAssignee
DuplicatesTarget the caller cannot openOffice or handlerComplaint marked as a duplicate of one outside the caller's viewduplicateOf is null and the marked_duplicate event's target reference is blank; the portal never names the targethidden referencecomplaint-read.service.ts
HandlingAttachment windowOffice or handlerAttach to an event older than 30 minutesRefusedCOMPLAINT_ATTACHMENT_EVENT_INVALIDcomplaint-attachment-surface.ts
HandlingAttachment visibility mismatchOffice or handlervisibility body field disagrees with the target event's own visibilityRefusedCOMPLAINT_ATTACHMENT_VISIBILITY_MISMATCHsame
HandlingAdmin vs handler uploader capacityOffice or handlerUploader holds Complaints_READ/superadmin vs only ComplaintAssignments_READRecorded as administrator vs handler capacityaffects masking of the uploader's nameresolveAdminAttachmentPlacement
ReopenStaff undoes a rejectionOfficereopen from rejected/cancelledAllowedstatus → reopenedACTION_FROM.reopen
ReopenComplainant tries the sameComplainantreopen_by_complainant from rejected/cancelledRefused — not in ACTION_FROMCOMPLAINT_INVALID_TRANSITIONACTION_FROM.reopen_by_complainant
Auto-closeResolution's snapshot mode differs from the school's current modeSystem (sweep)School switched to automatic after a manual resolutionThat resolution is never auto-closeddouble gatecomplaint-sweep-auto-close.pass.ts
EscalationRule scoped to a category/prioritySystem (sweep)Escalation rule with categoryId/priority setOnly matching complaints are candidatesscoped fetch pagerunEscalationPass
EscalationNo open assignment/requestSystem (sweep)Trigger's anchor condition not met (e.g. no_response with no assignee)Not a candidate this tickanchorFor returns nullcomplaint-sweep-escalation.pass.ts
Reminderspending_closure outside complainant_confirmation modeSystem (sweep)School's closureMode is manual/automaticReminder kind skipped for the whole tickchecked once per tickpendingClosureApplies
SweepFairness across ticksSystem (sweep)Many due candidates and a short time budgetThe starting reminder kind and escalation rule rotate each tick; each pass is isolated, so one failing pass does not stop the others; the last-success marker is written only for a tick with zero failuresno starvation of later kindscomplaint-sweep.service.ts
RemindersKind disabledSystem (sweep)Any of the seven complaint_reminder_settings rows with isEnabled: false (the seeded default for all seven)Skipped entirelyno reminders fire out of the boxrunRemindersPass
OverdueStatus pausedSystem (sweep)waiting_for_informationNever flagged overdueisOverdue excludes it via OPEN_STATUSEScomplaint-lifecycle.util.ts
AnonymityMasking applies everywhere except the complainant's own portal viewOffice or handlerAny admin reader, including ComplaintIdentity_READ holders (short of an explicit reveal)Name, user id, kind, contact line, actor identity, and uploader name all maskedsubmitterView/actorView/attachmentNameView/contactViewcomplaint-masking.util.ts
AnonymityMasked attachment filenameOffice or handlerComplainant's own upload on an anonymous complaintShown as attachment-<n>.<ext>, never the original filenamemaskedAttachmentNamecomplaint-masking.util.ts
VisibilitySubmitter excluded from admin surfaceStaff or teacherStaff/teacher who also filed a complaintCannot see it on the admin register even holding Complaints_READlistVisibilityWherecomplaint-access.service.ts
VisibilitySubject excluded from admin surfaceStaff memberThe staff member named as relatedStaffIdNever sees the complaint about them via the admin surfacesamesame
Office recordAmbiguous failureOfficeNamed complainant's profile is deleted/banned/cannot log in, or the wrong profile id kindSame COMPLAINT_COMPLAINANT_INVALID for every caseno distinguishing signalComplaintOfficeRecordService.resolveComplainant
Office recordRecorder cannot see their own recordingOfficeThe office records without naming a complainant, so the recorder is both submitter and recordercomplaint: null in the command result — allowed by contractbuildCommandResultComplaintOfficeRecordService.record

12.2 Business Process Diagram Pack

12.3 Business Rules and Policy Traceability

RuleBusiness ReasonActor ImpactEnforced InAPI ImpactBackend ImpactTests
Two-layer scoping: identity (deleted_at only) vs row-scopeA caller's own audience resolution must never 404 for a status flip elsewhereConsistent 404 vs empty-list behavior across every portal readcomplaint-portal-identity.ts, complaint-portal-audience.service.ts404 on a non-owned complaint, never a leaked row—integration tests on portal identity
Anonymous complaint masks identity for every admin readerProtects the complainant even from a permission-holding office userOnly an audited identity-reveal exposes itcomplaint-masking.util.tsconsistent DTO shape whether or not anonymoushistory row on reveal—
Submitter and subject excluded from admin visibilityA staff member cannot handle or see a complaint they filed or that is about themMust go through the portal for their own complaintComplaintAccessService.decide404 for those two actors——
start_investigation and the response/note/attachment status flip require the assigneeA triage administrator answering a question must not silently "start" the assignee's investigationStatus stays put unless the true handler actscomplaint-lifecycle.util.ts nextStatus———
SLA clock pauses during waiting_for_informationThe school should not be penalised for time spent waiting on the familydueAt reflects only active handling timecomplaint-sla.util.tsdue dates shown to admins stay meaningfulpausedSeconds accumulation—
Staff reopen may undo a rejection/cancellation; complainant reopen_by_complainant may notThe complainant only reopens an outcome the SCHOOL deliveredComplainant cannot force-reopen a legitimate rejectionACTION_FROM splitdistinct action names, distinct source-status lists——
Auto-close snapshots closureMode per resolutionA later school-wide mode switch must not retroactively close old resolutionsPredictable behavior regardless of when settings changedcomplaint-sweep-auto-close.pass.ts—double gate (current setting + snapshot)—
Exactly-once sweep firing via complaint_automation_logA retried or overlapping tick must not double-notifyNo duplicate reminders/escalations/auto-closestryClaimFiring (ON CONFLICT DO NOTHING RETURNING)—unique (complaintId, kind, ruleKey, cycle, sequence)—
404, never 403, for an invisible complaintAn id space over children's grievances must not become an enumeration oracleSame response whether nonexistent or merely invisibleComplaintAccessService.notFounduniform 404——
Every complaint action goes through assertCanPerformEvery service must ask the SAME state machine, never carry its own "from" listConsistent legal-transition behavior everywherecomplaint-lifecycle.util.tsone source of truth for 409s——

12.4 Tradeoffs and Product Rationale

Product DecisionUser BenefitEngineering BenefitAlternativeTradeoffRisk
Anonymity masks at read time, not at write timeIdentity is preserved for accountability (the audited reveal) while hidden by defaultOne row serves both masked and revealed viewsNever store identity for anonymous complaintsA genuine safeguarding reveal needs an audited path, which this providesLow — the reveal is itself logged
Reminders shipped disabled by default (all seven kinds)A school does not get surprised by automated nagging on day oneNo "empty state" reasoning needed for notification volumeEnabled by defaultReminders never fire until an administrator opts inMust be documented plainly so schools know to turn them on
Sweep passes process one candidate per transactionA single bad row cannot stall or fail the whole sweepSimpler recovery — retried automatically next tickOne large transaction per passSlightly more DB round-tripsMitigated by keyset paging and SKIP LOCKED
Submitter/subject excluded from admin visibility rather than merely restrictedStaff cannot pressure or retaliate against a complaint about themAccess decision stays in one function (decide)Separate "recuse" workflowAn administrator who is also a parent must use the portal for their own filingLow — consistent with the identity/row-scope split elsewhere
reopen (staff) vs reopen_by_complainant as distinct actionsA complainant cannot force-reopen a rejection the school made deliberatelyTwo entries in ACTION_FROM rather than a conditional branchOne reopen action with a role check inlineSlightly more surface in the lifecycle tableLow — makes the asymmetry explicit and testable

12.5 Flow Edge-Case Matrix

FlowEdge CaseTriggerExpected BehaviorUser/System FeedbackSource
SubmitEmpty state — no categories seeded(should not occur; categories are seeded)N/AN/Aschema seed
DraftFirst use — no prior draftsNew complainantNo draft-limit refusalDraft createdassertDraftLimit
AttachmentLast item at the cap20th of maxTotalAttachments (default 20)Allowed; 21st refusedCOMPLAINT_ATTACHMENT_LIMIT_REACHEDcomplaint-attachment.service.ts
Draft createDuplicate actionSame submissionId twiceIdempotent no-opreplayed: trueComplaintDraftService.insertDraft
Reassign / unassignConcurrent race on the same rowTwo admins act on one complaint at onceRow lock (FOR NO KEY UPDATE) serialises; loser's version fails409 version conflictassertVersion
ReopenExpired statePast reopenWindowDaysRefusedCOMPLAINT_REOPEN_WINDOW_EXPIREDComplaintPortalActionsService.reopen
HandlingPermission mismatchHandler without the specific action's permission codeRefused403/COMPLAINT_INVALID_TRANSITION-class per ACTION_PERMISSIONcomplaint-access.service.ts
PortalGuest limitationNo JWTRefused before reaching any handler401 (global JwtAuthGuard)app-wide guard
ConfigMissing dependencyTemplate references a category that was later deactivatedusableTemplate/usableCategory re-validate at use timecategory/template inactive errorcomplaint-filing.rules.ts
SweepCache stale/missN/A — sweeps read live rows under lock, no cache involved———
SweepQueue failureRedis/DB hiccup on enqueueSweepLogged, swallowed, next tick unaffectedserver log onlycomplaint.scheduler.ts
ListUnsupported filter/sort optionN/A beyond documented filtersValidated at the DTO layer400 validationregister DTOs

12.6 Flow-to-Data Trace

FlowReadsWritesCacheJobs/EventsResponse Fields
Submitdraft row, category, SLA rule/template/prioritycomplaints (status, ackDueAt, dueAt), complaint_events——status, ackDueAt, dueAt, dueSource
Resolvecomplaint row (locked)complaint_resolutions, complaints (status), complaint_events—complainant notificationstatus: resolved, resolution summary
Sweep tick (any pass)complaints matching status/date predicate, rules/settingscomplaint_automation_log, complaint_events, sometimes complaints—notification per firing(async — no direct HTTP response)

12.7 Experience Quality Checklist

  • The doc explains what the actor is trying to accomplish.
  • The doc explains what the backend does that the actor does not see (masking, SLA clock, sweep exactly-once firing).
  • The doc covers every minor flow and branch (§12.1, §12.5).
  • The doc includes complainant, handler, office, and system flows.
  • The doc explains business logic, tradeoffs, and rationale (§12.3-12.4).
  • The doc maps every flow to API routes and backend side effects.
  • The doc includes diagrams appropriate to each flow type.
  • The doc covers edge cases and failure recovery (§10, §12.5).

13. Completion Checklist

  • Every feature, minor action, and submodule capability is listed.
  • Every actor has allowed and forbidden behavior.
  • Every major and minor flow includes steps, branches, and diagrams.
  • Every lifecycle has a transition table and state diagram.
  • Every flow links to the API and backend docs.

See Also

  • API doc: /docs/developer/complaints/api
  • Backend doc: /docs/developer/complaints/backend