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 Type | Files or Docs | What Was Extracted |
|---|---|---|
| API | Sibling 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.ts | Route surface, actors, permissions |
| Backend | Sibling backend doc, the services and schema it documents | Business behavior, side effects, lifecycle |
| Lifecycle | apps/api/src/utils/complaints/complaint-lifecycle.util.ts | The state machine: actions, source statuses, next status, overdue rule |
| Schema | packages/db/src/schema/school/complaints.ts, packages/db/src/migrations/0040_complaints_integrity.sql | Constraints, settings, seeded categories and reminder kinds |
| Authorization | packages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.ts | Actor permissions |
2. Feature Summary
| Field | Value |
|---|---|
| Module | Complaints |
| Submodule | MyComplaints (portal), ComplaintAssignments, ComplaintTriage, ComplaintResponses, ComplaintInternalNotes, ComplaintInformationRequests, ComplaintAttachments, ComplaintResolution, ComplaintClosure, ComplaintReopen, ComplaintRejection, ComplaintCancellation, ComplaintEscalation, ComplaintIdentity, ComplaintHistory, ComplaintStatistics, ComplaintCategories, ComplaintTemplates, ComplaintPriorities, ComplaintSettings, ComplaintNotificationSettings |
| Primary user value | A 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. |
| Actors | Student, 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 outputs | Persisted complaints and their event timeline, resolutions, information requests, attachments, escalation/reminder notifications, complaint_automation_log firing records, statistics |
| Related docs | API, backend |
3. Actor Matrix
| Actor | Can Do | Cannot Do | Auth Requirement | Notes |
|---|---|---|---|---|
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 complaint | See 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 information | See complaints not assigned to them (without Complaints_READ); act if they are the complaint's own submitter or subject | Admin JWT + ComplaintAssignments_READ as the current assignee | start_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 behalf | See a complaint they submitted or that names them as the subject staff member; reveal an anonymous identity without ComplaintIdentity_READ | Admin JWT + Complaints_READ plus the per-action code | The 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-reveal | Reveal a complaint that is not anonymous (409 COMPLAINT_NOT_ANONYMOUS) or one they cannot see; reveal is allowed while archived | Admin JWT + ComplaintIdentity_READ | Every reveal writes an identity_viewed history row; masking still applies to every other reader |
| Superadmin | Sees and can act on every complaint regardless of submitter/subject exclusion | — | is_superadmin — cannot be scoped | The 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 window | Act outside its own sweep candidates; fire twice for the same anchor/cycle | Internal (BullMQ, cron) | Exactly-once per (kind, ruleKey, cycle, sequence) via complaint_automation_log |
4. Capability Matrix
| Capability | Surface | Actor | Route/Trigger | State Read | State Written | Linked API Section |
|---|---|---|---|---|---|---|
| File options / audience | Portal | Complainant | GET /api/mobile/complaints/form-options | categories, templates, priorities | — | API §form-options |
| Search a student/staff to name | Portal | Complainant | GET /api/mobile/complaints/student-options, /staff-options | students/staff | — | API |
| Start a draft or submit directly | Portal | Complainant | POST /api/mobile/complaints | categories, limits | complaints draft or submitted | API |
| List / read my complaints | Portal | Complainant | GET /api/mobile/complaints, /:id | complaints (own) | — | API |
| Edit / discard a draft | Portal | Complainant | PATCH/DELETE /api/mobile/complaints/:id | draft row | complaints, attachments cascade | API |
| Submit a draft | Portal | Complainant | POST /api/mobile/complaints/:id/submit | draft row | status, ackDueAt/dueAt, event | API |
| Answer / confirm / reopen / cancel | Portal | Complainant | .../information-requests/:id/respond, /confirm-resolution, /reopen, /cancel | own row | status, resolution, event, notification | API |
| Upload / download own attachment | Portal | Complainant | POST /api/mobile/complaints/:id/attachments, GET .../attachments/:attachmentId | own row | complaint_attachments | API |
| Record a complaint on behalf | Admin | Office | POST /api/complaints (office record path) | complainant profile | complaints submitted, never a draft | API |
| List / read the register | Admin | Office/handler | GET /api/complaints, /:id | visibility predicate | — | API |
| Acknowledge / assign / reassign / unassign | Admin | Office | POST /api/complaints/:id/{acknowledge,assign,reassign,unassign} | row + permission | status, assignedToUserId | API |
| Start investigation | Admin | Assignee | POST /api/complaints/:id/start | row, isAssignee | status → in_progress | API |
| Respond / note / attach | Admin | Handler/office | POST /api/complaints/:id/{responses,notes}, attachment upload | row | event, possibly status | API |
| Request / withdraw information | Admin | Office/handler | POST /api/complaints/:id/information-requests, .../withdraw | row | status, slaPausedAt, request row | API |
| Change priority / category / due date | Admin | Office | POST /api/complaints/:id/{priority,category,due-date} | row | fields, unchanged status | API |
| Resolve / close / reopen / reject / duplicate / cancel / escalate | Admin | Office | POST /api/complaints/:id/{resolve,close,reopen,reject,mark-duplicate,cancel,escalate} | row | status, resolution, event | API |
| Archive / unarchive | Admin | Office | POST /api/complaints/:id/{archive,unarchive} | row (terminal) | archivedAt | API |
| Reveal identity | Admin | Identity auditor | POST /api/complaints/:id/identity-reveal | row | audited history row only | API |
| Bulk operations | Admin | Office | POST /api/complaint-bulk/{acknowledge,assign,priority,category,close,reject,cancel,archive} | many rows (at most 100) | same effects, each item in its own transaction | API |
| Manage categories/templates/priorities/SLA/escalation rules | Admin | Office | /api/complaint-{categories,templates,priorities,sla-rules,escalation-rules}* | config tables | CRUD | API |
| Manage settings and reminder settings | Admin | Office | /api/complaint-settings* | complaint_settings, complaint_reminder_settings | version-guarded update | API |
| Statistics | Admin | Office | GET /api/complaint-statistics | aggregates | — | API |
| Pick an assignee or escalation recipient | Admin | Office | GET /api/complaint-assignable-users | live handlers | — | API |
| Manage notification settings | Admin | Office | /api/complaint-notification-settings* | complaint_notification_settings | version-guarded update | API |
| Reminders / overdue / escalation / auto-close sweeps | Worker | System | cron, 5 min | complaints, rules, settings | history, notification, complaint_automation_log | Backend |
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
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Pupil | POST /api/mobile/complaints with isAnonymous: true, submit: true | Draft row created then immediately submitted in one transaction | ComplaintPortalDraftService.create |
| 2 | Backend | Resolves audience, authorises no related student (anonymous cannot name one), computes SLA targets | ackDueAt/dueAt set, status submitted | ComplaintSubmissionService.submitDraft |
| 3 | Office | POST .../acknowledge | Status → acknowledged | complaint-actions.controller.ts |
| 4 | Office | POST .../assign to a handler | Status → assigned | same |
| 5 | Handler | POST .../start | Status → in_progress (only because the caller is the assignee) | complaint-lifecycle.util.ts |
| 6 | Handler | POST .../responses | Public response recorded on the timeline, masked to the complainant's identity | ComplaintMaskingUtil |
| 7 | Office/handler | POST .../resolve | Status → resolved, resolution row written | workflow commands |
| 8 | Pupil | POST /api/mobile/complaints/:id/confirm-resolution | Status → closed | ComplaintPortalActionsService.confirmResolution |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Anonymous + related student | isAnonymous: true with relatedStudentId set | Rejected | COMPLAINT_ANONYMOUS_STUDENT_NOT_ALLOWED |
| Category disallows anonymity | allowAnonymous: false on the chosen category | Category still usable non-anonymously; anonymity is validated against the category, not silently dropped | validation on submit |
| Duplicate submit | Same submissionId sent twice | Second call finds the row already created before any limit is checked, replayed: true | idempotent create |
| Anonymous with a document attached | The pupil attached a .pdf or .docx, then files anonymously | Refused: an anonymous complaint accepts only jpg, jpeg, png, webp and txt from the complainant, because document properties carry the author's name | 422 COMPLAINT_ATTACHMENT_ANONYMOUS_TYPE_REFUSED |
| Handler answers without starting | Office (not the assignee) adds a response on an assigned row | Status stays assigned — only the assignee's own action starts investigation | nextStatus callerIsAssignee guard |
Non-assignee tries to start | Any other handler | Refused | COMPLAINT_INVALID_TRANSITION (from assertCanPerform) |
| Identity revealed | Identity auditor calls identity-reveal | Audited identity_viewed history row written; masking continues for everyone else | ComplaintOutcomeActionsController.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, orreopened. - Actor holds
ComplaintInformationRequests_CREATE.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Handler | POST .../information-requests | Status → waiting_for_information, slaPausedAt recorded | complaint-handling.commands.ts |
| 2 | Guardian | POST /mobile/complaints/:id/information-requests/:requestId/respond | Status → in_progress, pausedSeconds accumulated via effects.clockResumeFields | ComplaintPortalActionsService.respondToInformationRequest |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| School withdraws instead | POST .../information-requests/:requestId/withdraw | Status returns to in_progress, clock resumes the same way | withdraw_information_request |
| Complainant answers a withdrawn/already-answered request | respondedAt or withdrawnAt already set | Refused | COMPLAINT_INFORMATION_REQUEST_NOT_FOUND |
| Reminder while waiting | pending_information reminder kind, if enabled | Complainant notified they still owe an answer | complaint-sweep-reminders.pass.ts |
| Overdue while waiting | waiting_for_information is not in OPEN_STATUSES | Never flagged overdue — the clock is paused | isOverdue |
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:
complainantCanReopenis true andnowis withinreopenWindowDaysof the current resolution.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1a | Complainant | POST .../confirm-resolution | Status → closed, closureKind: complainant | ComplaintPortalActionsService.confirmResolution |
| 1b | Office | POST .../close | Status → closed, closureKind: manual (workflow-side) | complaint-outcome.commands.ts |
| 1c | Worker | closureMode = automatic, autoCloseAfterHours elapsed since resolvedAt, resolution's own closureMode snapshot also automatic | Status → closed, complainant notified | complaint-sweep-auto-close.pass.ts |
| 2 | Complainant or staff | POST .../reopen (staff) or /mobile/.../reopen (complainant) | Status → reopened | complaint-lifecycle.util.ts reopen/reopen_by_complainant |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Complainant reopen outside window | now - resolvedAt > reopenWindowDays | Refused | COMPLAINT_REOPEN_WINDOW_EXPIRED |
| Complainant reopen disabled | complainantCanReopen: false | Refused | COMPLAINT_REOPEN_NOT_ALLOWED |
| Staff reopens a rejected/cancelled complaint | reopen (staff-only action) from rejected/cancelled | Allowed — undoes a mistaken rejection/cancellation | ACTION_FROM.reopen |
| Complainant reopens a rejected/cancelled complaint | reopen_by_complainant from rejected/cancelled | Refused — the complainant only reopens an outcome the school delivered | ACTION_FROM.reopen_by_complainant (only resolved/closed) |
| Auto-close after a mode switch | School was manual when resolved, later switched to automatic | That resolution's own snapshot is still manual — never retroactively auto-closed | complaint-sweep-auto-close.pass.ts double gate |
| Complainant cancels | complainantCanCancel: false | Refused | COMPLAINT_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.
| Entity | From | Event/Action | To | Guard Condition | Side Effects |
|---|---|---|---|---|---|
complaints | (new) | update_draft / submit | draft → submitted | draft only | ackDueAt/dueAt computed on submit |
complaints | submitted | acknowledge | acknowledged | ComplaintTriage_UPDATE | history event |
complaints | submitted/acknowledged/reopened | assign | assigned | ComplaintAssignments_CREATE | assignedToUserId set |
complaints | assigned/in_progress/waiting_for_information/reopened | reassign | assigned only if from reopened, else unchanged | ComplaintAssignments_UPDATE | new assignee |
complaints | assigned/reopened | unassign | acknowledged only if from assigned, else unchanged | ComplaintAssignments_DELETE | assignedToUserId cleared |
complaints | assigned/reopened | start_investigation | in_progress | caller is assignee | — |
complaints | ACTIVE_STATUSES (+resolved for note/attachment) | add_response/add_note/add_attachment | in_progress only from assigned/reopened AND caller is assignee, else unchanged | per-action permission | event, possibly status flip |
complaints | assigned/in_progress/reopened | request_information | waiting_for_information | ComplaintInformationRequests_CREATE | slaPausedAt set |
complaints | waiting_for_information | withdraw_information_request / respond_to_information_request | in_progress | delete code / complainant | clock resumes, pausedSeconds accumulated |
complaints | ACTIVE_STATUSES | change_priority/change_category/set_due_date | unchanged | per-action permission | field updated |
complaints | acknowledged/assigned/in_progress/waiting_for_information/reopened | resolve | resolved | ComplaintResolution_UPDATE | resolution row written |
complaints | resolved | close / confirm_resolution | closed | office / complainant respectively | closedAt set |
complaints | resolved/closed/rejected/cancelled | reopen (staff) | reopened | ComplaintReopen_UPDATE | staff may undo a rejection/cancellation |
complaints | resolved/closed only | reopen_by_complainant | reopened | within reopen window, complainantCanReopen | complainant cannot reopen a rejection/cancellation |
complaints | ACTIVE_STATUSES | reject/mark_duplicate | rejected | ComplaintRejection_UPDATE | event; 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. |
complaints | ACTIVE_STATUSES | cancel | cancelled | complainant (complainantCanCancel) or office | event |
complaints | ACTIVE_STATUSES | escalate | unchanged | ComplaintEscalation_UPDATE or system sweep | escalationLevel +1, notification |
complaints | TERMINAL_STATUSES | archive | archived (status unchanged) | not already archived | archivedAt set |
complaints | archived | unarchive | not archived | must currently be archived | archivedAt cleared |
complaints | any submitted status | reveal_identity | unchanged | ComplaintIdentity_READ; allowed even while archived | audited identity_viewed history row |
Diagram (statuses only; archived is an orthogonal flag on any terminal status):
9. Data and Side Effects by Flow
| Flow | DB Writes | Cache Effects | Jobs | Realtime | Analytics | Notifications |
|---|---|---|---|---|---|---|
| Draft / submit | complaints (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 / start | complaints, complaint_assignments, complaint_events | — | — | — | — | acknowledged_complainant, assigned_assignee, assigned_complainant (seeded off), reassigned_previous_assignee; start sends none |
| Response / note / attachment | complaint_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 / withdraw | complaint_info_requests, complaints (status, slaPausedAt) | — | — | — | — | assignee/triage or complainant notified |
| Resolve / close / reopen / reject / cancel | complaint_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 sweep | complaint_automation_log | — | — | — | — | staff reminders to the live assignee and triage, de-duplicated; complainant reminders to the complainant |
| Overdue sweep | complaint_events, complaint_automation_log | — | — | — | — | live assignee and triage, de-duplicated |
| Auto-close sweep | complaints (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 reveal | complaint_events (identity_viewed) | — | — | — | — | — |
10. Error and Recovery Flows
| Scenario | Trigger | User/System Experience | Recovery | Source |
|---|---|---|---|---|
| Concurrent edits | Two writers act on the same complaint | Second write's version no longer matches | 409 version conflict, reload and reapply | assertVersion |
| Invalid transition | Action attempted from a status not in ACTION_FROM[action] | Refused | COMPLAINT_INVALID_TRANSITION | complaint-errors.util.ts |
| Archived complaint | Any action except unarchive/reveal_identity on an archived row | Refused | archived-state error | canPerform |
| Sweep candidate fails | An exception during one candidate's transaction in a sweep pass | Logged, loop continues to the next candidate | Caught per-candidate, not per-pass | runSweepPass |
| Priority chosen when not allowed | A complainant sends priority while the school sets it | Draft save refused | COMPLAINT_PRIORITY_NOT_SELECTABLE; the filing form hides the field and drops a resumed draft's priority | ComplaintDraftService.assertPriorityChoosable |
| Duplicate sweep firing | Two sweep ticks race on the same anchor/cycle | Only one wins the ON CONFLICT DO NOTHING RETURNING id claim | The loser's logId is null, no duplicate notification | tryClaimFiring |
| Complainant reopen window expired | now - resolvedAt > reopenWindowDays | Refused | COMPLAINT_REOPEN_WINDOW_EXPIRED | ComplaintPortalActionsService.reopen |
| Complaint not visible | Caller lacks Complaints_READ/assignment, or is the complainant (the submitter of anything but an office record with nobody named) or the subject | 404, not 403 — avoids an existence oracle over children's grievances | ComplaintAccessService.notFound | same |
| Own complaint not found (portal) | Any id the caller did not submit, or one the caller recorded as office staff | 404 COMPLAINT_NOT_FOUND | same reasoning as the admin surface | loadOwnComplaintRow |
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
| Feature | Minor Behavior | Actor | Trigger | User/System Result | Backend Side Effect | Source |
|---|---|---|---|---|---|---|
| Draft | Resubmitted submissionId | Complainant | POST with the same submissionId | No duplicate row | replayed: true returned | ComplaintDraftService.insertDraft |
| Draft | Draft limit reached | Complainant | Too many open drafts | Refused | draft-limit error | ComplaintDraftService.assertDraftLimit |
| Submit | Daily submission limit | Complainant | dailySubmissionLimit (default 10) already used today | Refused | daily-limit error | assertDailySubmissionLimit |
| Submit | Anonymous + related student | Complainant | isAnonymous: true with a named student | Refused | COMPLAINT_ANONYMOUS_STUDENT_NOT_ALLOWED | complaint-filing.rules.ts |
| Submit | Related student not the caller's own (student audience) | Student | Student caller names a different student | Refused | COMPLAINT_RELATED_STUDENT_NOT_FOUND | authoriseRelatedStudent |
| Submit | Related student not the guardian's child | Guardian | Guardian names a non-child | Refused | COMPLAINT_RELATED_STUDENT_NOT_FOUND | same |
| Submit | Teacher/staff without Students_READ names a student | Teacher or staff | Staff/teacher caller | Refused | COMPLAINT_RELATED_STUDENT_NOT_FOUND | same |
| Answers | Unknown answer key | Complainant | Answer not among the category's questions | Refused | COMPLAINT_ANSWERS_INVALID | assertAnswers |
| Answers | Required question blank at submit | Complainant | Required question left empty | Refused | COMPLAINT_REQUIRED_FIELD_MISSING | same |
| Handling | Non-assignee responds | Office or handler | Office/handler not the assignee, row assigned/reopened | Response recorded but status does NOT flip to in_progress | no status change | nextStatus |
| Submit | Role switched since the draft was saved | Complainant | Draft filed as guardian, submitted under a staff role | Refused | COMPLAINT_SUBMITTER_KIND_CHANGED | ComplaintPortalDraftService.submit |
| Submit | Named student no longer authorised | Complainant | A guardian-child link was removed after the draft was saved | Refused at submit | COMPLAINT_RELATED_STUDENT_NOT_FOUND | authoriseRelatedStudent re-run at submit |
| Anonymity | Document type on an anonymous complaint | Complainant | Upload, submit, or a draft saved as anonymous with a file other than jpg, jpeg, png, webp or txt | Refused | COMPLAINT_ATTACHMENT_ANONYMOUS_TYPE_REFUSED | complaint-attachment-anonymity.ts |
| Anonymity | Assigning the anonymous submitter | Office | Triage picks the person who filed an anonymous complaint | Allowed, so no refusal singles out the author; the assignee still cannot see it because visibility excludes the submitter | no error | isEligibleAssignee |
| Duplicates | Target the caller cannot open | Office or handler | Complaint marked as a duplicate of one outside the caller's view | duplicateOf is null and the marked_duplicate event's target reference is blank; the portal never names the target | hidden reference | complaint-read.service.ts |
| Handling | Attachment window | Office or handler | Attach to an event older than 30 minutes | Refused | COMPLAINT_ATTACHMENT_EVENT_INVALID | complaint-attachment-surface.ts |
| Handling | Attachment visibility mismatch | Office or handler | visibility body field disagrees with the target event's own visibility | Refused | COMPLAINT_ATTACHMENT_VISIBILITY_MISMATCH | same |
| Handling | Admin vs handler uploader capacity | Office or handler | Uploader holds Complaints_READ/superadmin vs only ComplaintAssignments_READ | Recorded as administrator vs handler capacity | affects masking of the uploader's name | resolveAdminAttachmentPlacement |
| Reopen | Staff undoes a rejection | Office | reopen from rejected/cancelled | Allowed | status → reopened | ACTION_FROM.reopen |
| Reopen | Complainant tries the same | Complainant | reopen_by_complainant from rejected/cancelled | Refused — not in ACTION_FROM | COMPLAINT_INVALID_TRANSITION | ACTION_FROM.reopen_by_complainant |
| Auto-close | Resolution's snapshot mode differs from the school's current mode | System (sweep) | School switched to automatic after a manual resolution | That resolution is never auto-closed | double gate | complaint-sweep-auto-close.pass.ts |
| Escalation | Rule scoped to a category/priority | System (sweep) | Escalation rule with categoryId/priority set | Only matching complaints are candidates | scoped fetch page | runEscalationPass |
| Escalation | No open assignment/request | System (sweep) | Trigger's anchor condition not met (e.g. no_response with no assignee) | Not a candidate this tick | anchorFor returns null | complaint-sweep-escalation.pass.ts |
| Reminders | pending_closure outside complainant_confirmation mode | System (sweep) | School's closureMode is manual/automatic | Reminder kind skipped for the whole tick | checked once per tick | pendingClosureApplies |
| Sweep | Fairness across ticks | System (sweep) | Many due candidates and a short time budget | The 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 failures | no starvation of later kinds | complaint-sweep.service.ts |
| Reminders | Kind disabled | System (sweep) | Any of the seven complaint_reminder_settings rows with isEnabled: false (the seeded default for all seven) | Skipped entirely | no reminders fire out of the box | runRemindersPass |
| Overdue | Status paused | System (sweep) | waiting_for_information | Never flagged overdue | isOverdue excludes it via OPEN_STATUSES | complaint-lifecycle.util.ts |
| Anonymity | Masking applies everywhere except the complainant's own portal view | Office or handler | Any admin reader, including ComplaintIdentity_READ holders (short of an explicit reveal) | Name, user id, kind, contact line, actor identity, and uploader name all masked | submitterView/actorView/attachmentNameView/contactView | complaint-masking.util.ts |
| Anonymity | Masked attachment filename | Office or handler | Complainant's own upload on an anonymous complaint | Shown as attachment-<n>.<ext>, never the original filename | maskedAttachmentName | complaint-masking.util.ts |
| Visibility | Submitter excluded from admin surface | Staff or teacher | Staff/teacher who also filed a complaint | Cannot see it on the admin register even holding Complaints_READ | listVisibilityWhere | complaint-access.service.ts |
| Visibility | Subject excluded from admin surface | Staff member | The staff member named as relatedStaffId | Never sees the complaint about them via the admin surface | same | same |
| Office record | Ambiguous failure | Office | Named complainant's profile is deleted/banned/cannot log in, or the wrong profile id kind | Same COMPLAINT_COMPLAINANT_INVALID for every case | no distinguishing signal | ComplaintOfficeRecordService.resolveComplainant |
| Office record | Recorder cannot see their own recording | Office | The office records without naming a complainant, so the recorder is both submitter and recorder | complaint: null in the command result — allowed by contract | buildCommandResult | ComplaintOfficeRecordService.record |
12.2 Business Process Diagram Pack
12.3 Business Rules and Policy Traceability
| Rule | Business Reason | Actor Impact | Enforced In | API Impact | Backend Impact | Tests |
|---|---|---|---|---|---|---|
Two-layer scoping: identity (deleted_at only) vs row-scope | A caller's own audience resolution must never 404 for a status flip elsewhere | Consistent 404 vs empty-list behavior across every portal read | complaint-portal-identity.ts, complaint-portal-audience.service.ts | 404 on a non-owned complaint, never a leaked row | — | integration tests on portal identity |
| Anonymous complaint masks identity for every admin reader | Protects the complainant even from a permission-holding office user | Only an audited identity-reveal exposes it | complaint-masking.util.ts | consistent DTO shape whether or not anonymous | history row on reveal | — |
| Submitter and subject excluded from admin visibility | A staff member cannot handle or see a complaint they filed or that is about them | Must go through the portal for their own complaint | ComplaintAccessService.decide | 404 for those two actors | — | — |
start_investigation and the response/note/attachment status flip require the assignee | A triage administrator answering a question must not silently "start" the assignee's investigation | Status stays put unless the true handler acts | complaint-lifecycle.util.ts nextStatus | — | — | — |
SLA clock pauses during waiting_for_information | The school should not be penalised for time spent waiting on the family | dueAt reflects only active handling time | complaint-sla.util.ts | due dates shown to admins stay meaningful | pausedSeconds accumulation | — |
Staff reopen may undo a rejection/cancellation; complainant reopen_by_complainant may not | The complainant only reopens an outcome the SCHOOL delivered | Complainant cannot force-reopen a legitimate rejection | ACTION_FROM split | distinct action names, distinct source-status lists | — | — |
Auto-close snapshots closureMode per resolution | A later school-wide mode switch must not retroactively close old resolutions | Predictable behavior regardless of when settings changed | complaint-sweep-auto-close.pass.ts | — | double gate (current setting + snapshot) | — |
Exactly-once sweep firing via complaint_automation_log | A retried or overlapping tick must not double-notify | No duplicate reminders/escalations/auto-closes | tryClaimFiring (ON CONFLICT DO NOTHING RETURNING) | — | unique (complaintId, kind, ruleKey, cycle, sequence) | — |
| 404, never 403, for an invisible complaint | An id space over children's grievances must not become an enumeration oracle | Same response whether nonexistent or merely invisible | ComplaintAccessService.notFound | uniform 404 | — | — |
Every complaint action goes through assertCanPerform | Every service must ask the SAME state machine, never carry its own "from" list | Consistent legal-transition behavior everywhere | complaint-lifecycle.util.ts | one source of truth for 409s | — | — |
12.4 Tradeoffs and Product Rationale
| Product Decision | User Benefit | Engineering Benefit | Alternative | Tradeoff | Risk |
|---|---|---|---|---|---|
| Anonymity masks at read time, not at write time | Identity is preserved for accountability (the audited reveal) while hidden by default | One row serves both masked and revealed views | Never store identity for anonymous complaints | A genuine safeguarding reveal needs an audited path, which this provides | Low — the reveal is itself logged |
| Reminders shipped disabled by default (all seven kinds) | A school does not get surprised by automated nagging on day one | No "empty state" reasoning needed for notification volume | Enabled by default | Reminders never fire until an administrator opts in | Must be documented plainly so schools know to turn them on |
| Sweep passes process one candidate per transaction | A single bad row cannot stall or fail the whole sweep | Simpler recovery — retried automatically next tick | One large transaction per pass | Slightly more DB round-trips | Mitigated by keyset paging and SKIP LOCKED |
| Submitter/subject excluded from admin visibility rather than merely restricted | Staff cannot pressure or retaliate against a complaint about them | Access decision stays in one function (decide) | Separate "recuse" workflow | An administrator who is also a parent must use the portal for their own filing | Low — consistent with the identity/row-scope split elsewhere |
reopen (staff) vs reopen_by_complainant as distinct actions | A complainant cannot force-reopen a rejection the school made deliberately | Two entries in ACTION_FROM rather than a conditional branch | One reopen action with a role check inline | Slightly more surface in the lifecycle table | Low — makes the asymmetry explicit and testable |
12.5 Flow Edge-Case Matrix
| Flow | Edge Case | Trigger | Expected Behavior | User/System Feedback | Source |
|---|---|---|---|---|---|
| Submit | Empty state — no categories seeded | (should not occur; categories are seeded) | N/A | N/A | schema seed |
| Draft | First use — no prior drafts | New complainant | No draft-limit refusal | Draft created | assertDraftLimit |
| Attachment | Last item at the cap | 20th of maxTotalAttachments (default 20) | Allowed; 21st refused | COMPLAINT_ATTACHMENT_LIMIT_REACHED | complaint-attachment.service.ts |
| Draft create | Duplicate action | Same submissionId twice | Idempotent no-op | replayed: true | ComplaintDraftService.insertDraft |
| Reassign / unassign | Concurrent race on the same row | Two admins act on one complaint at once | Row lock (FOR NO KEY UPDATE) serialises; loser's version fails | 409 version conflict | assertVersion |
| Reopen | Expired state | Past reopenWindowDays | Refused | COMPLAINT_REOPEN_WINDOW_EXPIRED | ComplaintPortalActionsService.reopen |
| Handling | Permission mismatch | Handler without the specific action's permission code | Refused | 403/COMPLAINT_INVALID_TRANSITION-class per ACTION_PERMISSION | complaint-access.service.ts |
| Portal | Guest limitation | No JWT | Refused before reaching any handler | 401 (global JwtAuthGuard) | app-wide guard |
| Config | Missing dependency | Template references a category that was later deactivated | usableTemplate/usableCategory re-validate at use time | category/template inactive error | complaint-filing.rules.ts |
| Sweep | Cache stale/miss | N/A — sweeps read live rows under lock, no cache involved | — | — | — |
| Sweep | Queue failure | Redis/DB hiccup on enqueueSweep | Logged, swallowed, next tick unaffected | server log only | complaint.scheduler.ts |
| List | Unsupported filter/sort option | N/A beyond documented filters | Validated at the DTO layer | 400 validation | register DTOs |
12.6 Flow-to-Data Trace
| Flow | Reads | Writes | Cache | Jobs/Events | Response Fields |
|---|---|---|---|---|---|
| Submit | draft row, category, SLA rule/template/priority | complaints (status, ackDueAt, dueAt), complaint_events | — | — | status, ackDueAt, dueAt, dueSource |
| Resolve | complaint row (locked) | complaint_resolutions, complaints (status), complaint_events | — | complainant notification | status: resolved, resolution summary |
| Sweep tick (any pass) | complaints matching status/date predicate, rules/settings | complaint_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