Complaints API Reference
Complete API contracts for Complaints, including routes, auth, DTOs, responses, errors, examples, and integration notes.
Complaints - API Reference
Audience: Frontend engineers, panel engineers, backend engineers, QA, and API consumers.
Scope: The admin register, workflow and bulk actions, attachments, statistics, configuration (categories, templates, priorities, SLA rules, escalation rules, settings, reminders, notification settings), and the complainant-facing portal at /api/mobile/complaints.
1. Documentation Evidence
| Area | Files Inspected | What Was Verified |
|---|---|---|
| Routes | apps/api/test/structure/structure.baseline.json (jq -r '.routes.all[]', filtered on complaint) | 82 routes, the authoritative surface |
| Register controllers | modules/complaints/register/complaints.controller.ts, complaint-statistics.controller.ts, complaint-assignable-users.controller.ts | Paths, @Permissions(), handler names, status codes, audit writes |
| Workflow controllers | modules/complaints/workflow/complaint-actions.controller.ts, complaint-outcome-actions.controller.ts, complaint-bulk.controller.ts | One route per action, body DTOs, permissions |
| Attachment controllers | modules/complaints/attachments/complaint-attachments.controller.ts, portal/complaint-portal-attachments.controller.ts | Multipart upload, download, allowlist entry |
| Config controllers | modules/complaints/config/*.controller.ts (7 files) | CRUD surfaces and the codes that gate them |
| Portal controllers | modules/complaints/portal/complaint-portal.controller.ts, complaint-portal-actions.controller.ts | Complainant routes and their codes |
| Request DTOs | register/dto/*.ts, workflow/dto/*.ts, attachments/dto/*.ts, config/dto/*.request.dto.ts, portal/dto/*.ts | Every field, validator, default |
| Response DTOs | shared/dto/complaint-response.dto.ts, portal/dto/complaint-form-options.dto.ts, config/dto/*.response.dto.ts | Every field and its nullability |
| Services | shared/complaint-access.service.ts, shared/complaint-submission.service.ts, shared/complaint-draft.service.ts, shared/complaint-filing.rules.ts, workflow/*.commands.ts, workflow/complaint-bulk.service.ts, portal/*.service.ts, register/*.service.ts, attachments/*.ts, config/*.service.ts | Validation order, side effects, error codes |
| Errors | apps/api/src/common/types/error-codes.ts (the COMPLAINT_ block) and every throw site in the module | Each code's HTTP status and condition |
| Permissions | packages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.ts, apps/api/src/common/authorization/role.guard.ts | Modules, seeded grants, the four allowlisted handlers (ComplaintsController.findOne, .listEvents, ComplaintAttachmentsController.download, ComplaintAssignableUsersController.list) |
| Schema | packages/db/src/schema/school/complaints.ts | Enums, persisted fields, constraints |
| Jobs | packages/jobs/src/index.ts, modules/complaints/workers/complaint.scheduler.ts | The one queue and job |
2. Module Summary
| Field | Value |
|---|---|
| Module name | Complaints |
| Module slug | complaints |
| Primary actors | Complainants (student, guardian, teacher, staff, office administrator), the triage office, the handler (current assignee), a superadmin, the automation sweep |
| API surfaces | Admin (/api/complaints*, /api/complaint-*) and portal (/api/mobile/complaints*) |
| Base route prefixes | /api/complaints, /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, /api/mobile/complaints |
| Auth model | Global JwtAuthGuard, then RoleGuard. Every route declares @Permissions() except four allowlisted admin handlers that resolve access per complaint in the service |
| Persistence | PostgreSQL (Drizzle), BullMQ (QueueName.COMPLAINTS), object storage (upload type complaint-attachment), Redis (one sweep marker) |
| Runtime source of truth | The complaints row and its child tables; ComplaintAccessService for who sees what |
| Swagger placement | Admin controllers appear in /api/api-docs; portal controllers appear in /api/mobile-docs |
| Sibling docs | Backend, Features and flows |
3. Concepts and Terminology
| Term | Meaning | Source File | Used By |
|---|---|---|---|
id | The complaint's uuid. A complaint has no separate publicId; its id is the client-facing identifier | schema/school/complaints.ts | Every complaint route |
publicId | The client-facing uuid of a category, template, SLA rule or escalation rule | schema/school/complaints.ts | Config routes |
reference | CMP- and eight random Crockford base32 characters, e.g. CMP-7K3QX9MB (never I, L, O or U). Issued once at submission and never changed; a draft has none. It is not a sequence number: neighbouring complaints have unrelated references, and filing order comes from submittedAt | complaints.reference (assigned by the complaints_assign_reference trigger) | List, detail, notifications |
version | The optimistic-concurrency counter. Every command sends back the value it last read as expectedVersion | complaints.version | Every mutating complaint route |
expectedVersion | The version the caller last read. A mismatch answers 409 COMPLAINT_VERSION_CONFLICT (complaints) or 409 COMPLAINT_CONFIG_VERSION_CONFLICT (config rows) | workflow/dto/complaint-action.dto.ts, config/dto/complaint-config-common.dto.ts | Commands, config updates |
| Visibility | On the admin surface a complaint is visible to a superadmin, or to a caller who is neither its submitter nor its subject and who holds Complaints_READ, or holds ComplaintAssignments_READ as the current assignee. A draft is never visible there | shared/complaint-access.service.ts | Every admin complaint route |
| Subject | The staff member a complaint names in relatedStaffId. They never see it on the admin surface | shared/complaint-access.service.ts | Visibility, assignment eligibility |
| Anonymity | isAnonymous masks the submitter on every admin read. The identity is stored and only leaves through the audited identity-reveal route | shared/complaint-masking.util.ts | Responses, reveal |
| Office record | A complaint the office files through POST /api/complaints, on someone's behalf or as the office itself. recordedByUserId names the recorder | register/complaint-office-record.service.ts | Register, portal |
| Portal audience | Who the caller files as: student, guardian, teacher, staff or admin, resolved from the active role's scopeKind and a live profile row, never from a request field | portal/complaint-portal-audience.service.ts | Portal routes |
allowedActions | The exact actions the caller may take now, computed server-side from status, archive state, permissions and assignment | complaint-lifecycle.util.ts, ComplaintAccessService.heldActions | Detail responses |
dueSource | Which configuration supplied the resolution deadline: priority, category, sla_rule, template or manual | utils/complaints/complaint-sla.util.ts | Detail |
| SLA pause | While waiting_for_information, the clock is paused; the paused time is added to the deadline when it resumes | complaints.sla_paused_at, sla_paused_seconds | Workflow, portal |
schoolTimezone | The school's IANA timezone (for example Asia/Kathmandu), read from school_profile.timezone and defaulting to Asia/Kathmandu when that row is absent. Every instant in a complaint response is UTC ISO-8601; render it in this zone | ComplaintPolicyService.timezone() via SchoolClockService.timezone() | Register list and detail, portal list and detail, form options |
4. API Surface Map
82 routes, matching structure.baseline.json exactly. "Guards" is always the global JwtAuthGuard followed by RoleGuard.
| Surface | Method | Path | Actor | Permission | Controller |
|---|---|---|---|---|---|
| Admin | GET | /api/complaints | Office | Complaints_READ | ComplaintsController.findAll |
| Admin | GET | /api/complaints/assigned-to-me | Handler | ComplaintAssignments_READ | ComplaintsController.findAssignedToMe |
| Admin | POST | /api/complaints | Office | Complaints_CREATE | ComplaintsController.record |
| Admin | GET | /api/complaints/{id} | Office, handler | None declared (allowlisted) | ComplaintsController.findOne |
| Admin | GET | /api/complaints/{id}/events | Office, handler | None declared (allowlisted) | ComplaintsController.listEvents |
| Admin | POST | /api/complaints/{id}/acknowledge | Triage | ComplaintTriage_UPDATE | ComplaintActionsController.acknowledge |
| Admin | POST | /api/complaints/{id}/assign | Triage | ComplaintAssignments_CREATE | ComplaintActionsController.assign |
| Admin | POST | /api/complaints/{id}/reassign | Triage | ComplaintAssignments_UPDATE | ComplaintActionsController.reassign |
| Admin | POST | /api/complaints/{id}/unassign | Triage | ComplaintAssignments_DELETE | ComplaintActionsController.unassign |
| Admin | POST | /api/complaints/{id}/start | Current assignee | ComplaintAssignments_READ | ComplaintActionsController.start |
| Admin | POST | /api/complaints/{id}/responses | Handler | ComplaintResponses_CREATE | ComplaintActionsController.addResponse |
| Admin | POST | /api/complaints/{id}/notes | Handler | ComplaintInternalNotes_CREATE | ComplaintActionsController.addNote |
| Admin | POST | /api/complaints/{id}/information-requests | Handler | ComplaintInformationRequests_CREATE | ComplaintActionsController.requestInformation |
| Admin | POST | /api/complaints/{id}/information-requests/{requestId}/withdraw | Handler | ComplaintInformationRequests_DELETE | ComplaintActionsController.withdrawInformationRequest |
| Admin | POST | /api/complaints/{id}/priority | Triage | ComplaintPriorityChange_UPDATE | ComplaintActionsController.changePriority |
| Admin | POST | /api/complaints/{id}/category | Triage | ComplaintCategoryChange_UPDATE | ComplaintActionsController.changeCategory |
| Admin | POST | /api/complaints/{id}/due-date | Office | Complaints_UPDATE | ComplaintActionsController.setDueDate |
| Admin | POST | /api/complaints/{id}/resolve | Handler | ComplaintResolution_UPDATE | ComplaintOutcomeActionsController.resolve |
| Admin | POST | /api/complaints/{id}/close | Handler | ComplaintClosure_UPDATE | ComplaintOutcomeActionsController.close |
| Admin | POST | /api/complaints/{id}/reopen | Office | ComplaintReopen_UPDATE | ComplaintOutcomeActionsController.reopen |
| Admin | POST | /api/complaints/{id}/reject | Triage | ComplaintRejection_UPDATE | ComplaintOutcomeActionsController.reject |
| Admin | POST | /api/complaints/{id}/mark-duplicate | Triage | ComplaintRejection_UPDATE | ComplaintOutcomeActionsController.markDuplicate |
| Admin | POST | /api/complaints/{id}/cancel | Office | ComplaintCancellation_UPDATE | ComplaintOutcomeActionsController.cancel |
| Admin | POST | /api/complaints/{id}/escalate | Handler, office | ComplaintEscalation_UPDATE | ComplaintOutcomeActionsController.escalate |
| Admin | POST | /api/complaints/{id}/archive | Office | Complaints_DELETE | ComplaintOutcomeActionsController.archive |
| Admin | POST | /api/complaints/{id}/unarchive | Office | Complaints_RESTORE | ComplaintOutcomeActionsController.unarchive |
| Admin | POST | /api/complaints/{id}/identity-reveal | Authorised officer | ComplaintIdentity_READ | ComplaintOutcomeActionsController.revealIdentity |
| Admin | POST | /api/complaints/{id}/attachments | Handler | ComplaintAttachments_CREATE | ComplaintAttachmentsController.create |
| Admin | GET | /api/complaints/{id}/attachments/{attachmentId} | Office, handler | None declared (allowlisted) | ComplaintAttachmentsController.download |
| Admin | POST | /api/complaint-bulk/acknowledge | Triage | ComplaintTriage_UPDATE | ComplaintBulkController.acknowledge |
| Admin | POST | /api/complaint-bulk/assign | Triage | ComplaintAssignments_CREATE | ComplaintBulkController.assign |
| Admin | POST | /api/complaint-bulk/priority | Triage | ComplaintPriorityChange_UPDATE | ComplaintBulkController.priority |
| Admin | POST | /api/complaint-bulk/category | Triage | ComplaintCategoryChange_UPDATE | ComplaintBulkController.category |
| Admin | POST | /api/complaint-bulk/close | Handler | ComplaintClosure_UPDATE | ComplaintBulkController.close |
| Admin | POST | /api/complaint-bulk/reject | Triage | ComplaintRejection_UPDATE | ComplaintBulkController.reject |
| Admin | POST | /api/complaint-bulk/cancel | Office | ComplaintCancellation_UPDATE | ComplaintBulkController.cancel |
| Admin | POST | /api/complaint-bulk/archive | Office | Complaints_DELETE | ComplaintBulkController.archive |
| Admin | GET | /api/complaint-statistics | Office | ComplaintStatistics_READ | ComplaintStatisticsController.get |
| Admin | GET | /api/complaint-assignable-users | Triage, escalation config | None declared (allowlisted) | ComplaintAssignableUsersController.list |
| Admin | GET | /api/complaint-categories | Office | ComplaintCategories_READ | ComplaintCategoriesController.list |
| Admin | GET | /api/complaint-categories/{publicId} | Office | ComplaintCategories_READ | ComplaintCategoriesController.findOne |
| Admin | POST | /api/complaint-categories | Office | ComplaintCategories_CREATE | ComplaintCategoriesController.create |
| Admin | PATCH | /api/complaint-categories/{publicId} | Office | ComplaintCategories_UPDATE | ComplaintCategoriesController.update |
| Admin | DELETE | /api/complaint-categories/{publicId} | Office | ComplaintCategories_DELETE | ComplaintCategoriesController.remove |
| Admin | POST | /api/complaint-categories/{publicId}/restore | Office | ComplaintCategories_RESTORE | ComplaintCategoriesController.restore |
| Admin | GET | /api/complaint-templates | Office | ComplaintTemplates_READ | ComplaintTemplatesController.list |
| Admin | GET | /api/complaint-templates/{publicId} | Office | ComplaintTemplates_READ | ComplaintTemplatesController.findOne |
| Admin | POST | /api/complaint-templates | Office | ComplaintTemplates_CREATE | ComplaintTemplatesController.create |
| Admin | PATCH | /api/complaint-templates/{publicId} | Office | ComplaintTemplates_UPDATE | ComplaintTemplatesController.update |
| Admin | DELETE | /api/complaint-templates/{publicId} | Office | ComplaintTemplates_DELETE | ComplaintTemplatesController.remove |
| Admin | POST | /api/complaint-templates/{publicId}/restore | Office | ComplaintTemplates_RESTORE | ComplaintTemplatesController.restore |
| Admin | GET | /api/complaint-priorities | Office | ComplaintPriorities_READ | ComplaintPrioritiesController.list |
| Admin | PATCH | /api/complaint-priorities/{priority} | Office | ComplaintPriorities_UPDATE | ComplaintPrioritiesController.update |
| Admin | GET | /api/complaint-sla-rules | Office | ComplaintPriorities_READ | ComplaintSlaRulesController.list |
| Admin | POST | /api/complaint-sla-rules | Office | ComplaintPriorities_CREATE | ComplaintSlaRulesController.create |
| Admin | PATCH | /api/complaint-sla-rules/{publicId} | Office | ComplaintPriorities_UPDATE | ComplaintSlaRulesController.update |
| Admin | DELETE | /api/complaint-sla-rules/{publicId} | Office | ComplaintPriorities_DELETE | ComplaintSlaRulesController.remove |
| Admin | GET | /api/complaint-escalation-rules | Office | ComplaintSettings_READ | ComplaintEscalationRulesController.list |
| Admin | POST | /api/complaint-escalation-rules | Office | ComplaintSettings_CREATE | ComplaintEscalationRulesController.create |
| Admin | PATCH | /api/complaint-escalation-rules/{publicId} | Office | ComplaintSettings_UPDATE | ComplaintEscalationRulesController.update |
| Admin | DELETE | /api/complaint-escalation-rules/{publicId} | Office | ComplaintSettings_DELETE | ComplaintEscalationRulesController.remove |
| Admin | POST | /api/complaint-escalation-rules/{publicId}/restore | Office | ComplaintSettings_RESTORE | ComplaintEscalationRulesController.restore |
| Admin | GET | /api/complaint-settings | Office | ComplaintSettings_READ | ComplaintSettingsController.get |
| Admin | PATCH | /api/complaint-settings | Office | ComplaintSettings_UPDATE | ComplaintSettingsController.update |
| Admin | PATCH | /api/complaint-settings/reminders/{kind} | Office | ComplaintSettings_UPDATE | ComplaintSettingsController.updateReminder |
| Admin | GET | /api/complaint-notification-settings | Office | ComplaintNotificationSettings_READ | ComplaintNotificationSettingsController.list |
| Admin | PATCH | /api/complaint-notification-settings/{eventKey} | Office | ComplaintNotificationSettings_UPDATE | ComplaintNotificationSettingsController.update |
| Portal | GET | /api/mobile/complaints/form-options | Complainant | MyComplaints_CREATE | ComplaintPortalController.formOptions |
| Portal | GET | /api/mobile/complaints/student-options | Complainant | MyComplaints_CREATE | ComplaintPortalController.studentOptions |
| Portal | GET | /api/mobile/complaints/staff-options | Complainant | MyComplaints_CREATE | ComplaintPortalController.staffOptions |
| Portal | GET | /api/mobile/complaints | Complainant | MyComplaints_READ | ComplaintPortalController.list |
| Portal | POST | /api/mobile/complaints | Complainant | MyComplaints_CREATE | ComplaintPortalController.create |
| Portal | GET | /api/mobile/complaints/{id} | Complainant | MyComplaints_READ | ComplaintPortalController.detail |
| Portal | PATCH | /api/mobile/complaints/{id} | Complainant | MyComplaints_CREATE | ComplaintPortalController.update |
| Portal | DELETE | /api/mobile/complaints/{id} | Complainant | MyComplaints_CREATE | ComplaintPortalController.remove |
| Portal | POST | /api/mobile/complaints/{id}/submit | Complainant | MyComplaints_CREATE | ComplaintPortalController.submit |
| Portal | POST | /api/mobile/complaints/{id}/information-requests/{requestId}/respond | Complainant | MyComplaints_UPDATE | ComplaintPortalActionsController.respond |
| Portal | POST | /api/mobile/complaints/{id}/confirm-resolution | Complainant | MyComplaints_UPDATE | ComplaintPortalActionsController.confirmResolution |
| Portal | POST | /api/mobile/complaints/{id}/reopen | Complainant | MyComplaints_UPDATE | ComplaintPortalActionsController.reopen |
| Portal | POST | /api/mobile/complaints/{id}/cancel | Complainant | MyComplaints_UPDATE | ComplaintPortalActionsController.cancel |
| Portal | POST | /api/mobile/complaints/{id}/attachments | Complainant | MyComplaints_UPDATE | ComplaintPortalAttachmentsController.uploadAttachment |
| Portal | GET | /api/mobile/complaints/{id}/attachments/{attachmentId} | Complainant | MyComplaints_READ | ComplaintPortalAttachmentsController.downloadAttachment |
The portal controllers declare @Controller("complaints") and reach /api/mobile/complaints through MOBILE_CHILDREN. The admin workflow, bulk and attachment controllers share the complaints root with ComplaintsController; no two paths collide.
5. Auth, Identity, and Permissions
| Surface | Guard/Decorator | Identity Shape | Permission | Guest Allowed | Notes |
|---|---|---|---|---|---|
Admin routes with @Permissions() | JwtAuthGuard, RoleGuard | req.user.id, activeRole | The code in §4, read from the ACTIVE role only | No | Most complaint-scoped codes also require visibility (§3), so holding a handling code does not open the register |
ComplaintsController.findOne, .listEvents, ComplaintAttachmentsController.download | JwtAuthGuard; RoleGuard passes them through NO_PERMISSION_ADMIN_HANDLERS | req.user.id, activeRole | None declared. ComplaintAccessService.resolveVisible requires Complaints_READ, or ComplaintAssignments_READ as the current assignee, and excludes the submitter and the subject. A superadmin always passes | No | The allowlist returns before RoleGuard checks for an active role, so ComplaintAccessService.permissionsOf refuses a missing active role with 403 AUTH_ACTIVE_ROLE_REQUIRED. An invisible complaint answers 404 COMPLAINT_NOT_FOUND |
ComplaintAssignableUsersController.list | Same allowlist | req.user.id, activeRole | Any of ComplaintAssignments_CREATE, ComplaintAssignments_UPDATE, ComplaintEscalation_UPDATE, ComplaintSettings_UPDATE | No | Returns only userId, name, designation, and is deliberately not filtered by any complaint's submitter |
| Portal | JwtAuthGuard, RoleGuard | req.user.id, activeRole.scopeKind | MyComplaints_READ, MyComplaints_CREATE or MyComplaints_UPDATE per §4 | No | Rows are resolved by submitter_user_id = actor.id AND (recorded_by_user_id IS NULL OR recorded_by_user_id <> actor.id). Anything else answers 404 COMPLAINT_NOT_FOUND |
5.1 Permission modules
Every module is a separate entry in PERMISSION_MODULES (permission-catalog.ts:524-575).
| Module | Gates |
|---|---|
Complaints | The whole register (_READ), office recording (_CREATE), manual due date (_UPDATE), archive (_DELETE), unarchive (_RESTORE) |
MyComplaints | The complainant's own complaints: read, file and drafts (_CREATE), follow-ups (_UPDATE) |
ComplaintAssignments | The assigned-to-me queue and starting an investigation (_READ), assign (_CREATE), reassign (_UPDATE), unassign (_DELETE) |
ComplaintTriage | Acknowledge (_UPDATE); holders with Complaints_READ also receive triage alerts |
ComplaintPriorityChange, ComplaintCategoryChange | Changing priority or category (_UPDATE) |
ComplaintResponses | Public responses (_CREATE), and public admin attachments |
ComplaintInternalNotes | Reading notes and internal attachments (_READ), writing notes and internal attachments (_CREATE) |
ComplaintInformationRequests | Asking (_CREATE) and withdrawing (_DELETE) |
ComplaintAttachments | The admin upload route (_CREATE) |
ComplaintResolution, ComplaintClosure, ComplaintReopen, ComplaintRejection, ComplaintCancellation, ComplaintEscalation | The matching outcome action (_UPDATE); ComplaintRejection_UPDATE also covers mark-duplicate |
ComplaintHistory | Internal, non-note history (_READ) |
ComplaintIdentity | Revealing an anonymous complainant (_READ); superadmin-only |
ComplaintStatistics | The dashboard (_READ) |
ComplaintCategories, ComplaintTemplates | Their CRUD and restore |
ComplaintPriorities | Priority targets and the category x priority SLA rules |
ComplaintSettings | General, closure, attachment and reminder settings, and escalation rules |
ComplaintNotificationSettings | Per-event notification switches |
ComplaintView | The panel's complaints hub screen; appears on no API route |
ComplaintAudit | Reading activity-log and notification-history entries about complaints; superadmin-only. Those entries name the complainant, so holding it is equivalent to identity access |
ComplaintIdentity and ComplaintAudit appear in SUPERADMIN_ONLY_MODULES (permission-catalog.ts:715-716), so no seeded role other than superadmin holds them; a school grants them down to a named role.
5.2 Seeded grants
| Role | Complaint codes (seed-auth.ts) |
|---|---|
superadmin | Every code |
staff, teacher (TEACHER_PERMISSIONS spreads STAFF_PERMISSIONS) | MyComplaints_READ, MyComplaints_CREATE, MyComplaints_UPDATE, ComplaintView_READ, ComplaintAssignments_READ, ComplaintResponses_CREATE, ComplaintInternalNotes_READ, ComplaintInternalNotes_CREATE, ComplaintInformationRequests_CREATE, ComplaintInformationRequests_DELETE, ComplaintAttachments_CREATE, ComplaintHistory_READ |
guardian, student | MyComplaints_READ, MyComplaints_CREATE, MyComplaints_UPDATE |
A staff member or teacher therefore handles only complaints assigned to them. Resolve, close, reject, cancel, reopen, escalate and assign are not seeded onto those roles.
5.3 Validation order on every admin command
ComplaintAccessService.resolveVisible states it and every command follows it: visibility (404), archived (409 COMPLAINT_ARCHIVED), transition (409 COMPLAINT_INVALID_TRANSITION), version (409 COMPLAINT_VERSION_CONFLICT), then input-specific checks.
6. DTO and Model Reference
All responses use the standard envelope { message, data, errorCode: null }. Paginated lists add count, currentPage and totalPage. Errors use the global envelope with message and errorCode.
6.1 Response: ComplaintListItemDto
| Field | Type | Nullable | Notes |
|---|---|---|---|
id | uuid | No | |
reference | string | Yes | null only for a draft, which the admin surface never returns |
title | string | No | |
status | ComplaintStatus | No | §7 |
priority | ComplaintPriority | No | |
category | { publicId, name, isActive, isDeleted } | No | Still resolves a deactivated or deleted category |
submitter | { userId, displayName, kind, isAnonymous } | No | For an unrevealed anonymous complaint userId, displayName and kind are null |
relatedStudent | { id, name, studentCode } | Yes | id is students.id |
gradeName, sectionName | string | Yes | Snapshotted at submission |
academicSession | { publicId, name } | Yes | |
assignee | { userId, name, isActive } | Yes | isActive is false when the account is deleted, banned or cannot log in |
submittedAt, ackDueAt, dueAt | ISO string | Yes | |
isOverdue | boolean | No | Open, past due, and not waiting for the complainant |
lastActivityAt | ISO string | No | |
resolvedAt, closedAt, archivedAt | ISO string | Yes | |
hasAttachments | boolean | No | |
escalationLevel, reopenCount | integer | No | |
version | integer | No | Send back as expectedVersion |
schoolTimezone | string | No | IANA zone, for example Asia/Kathmandu |
6.2 Response: ComplaintDetailDto (extends ComplaintListItemDto)
| Field | Type | Nullable | Notes |
|---|---|---|---|
description | string | No | |
answers | object | No | Keyed by question key; values are strings or numbers |
questionSnapshot | ComplaintQuestionDto[] | No | The category's questions as they were at submission |
incidentDate, incidentLocation | string | Yes | |
contactPreference | string | Yes | null for an unrevealed anonymous complaint |
relatedStaff | { id, name, employeeCode } | Yes | id is staff.id |
template | { publicId, name } | Yes | |
recordedBy | { userId, name, isActive } | Yes | The office user who recorded it |
acknowledgedAt, firstResponseAt | ISO string | Yes | |
dueSource | ComplaintDueSource | Yes | |
slaPaused | boolean | No | true while waiting for the complainant |
rejectionReason, cancellationReason | string | Yes | |
duplicateOf | { id, reference } | Yes | null unless the caller can open the target: Complaints_READ, or the caller is the target's assignee |
duplicates | { id, reference }[] | No | Complaints marked as duplicates of this one, limited to those the caller can open by the same rule |
assignments | ComplaintAssignmentDto[] | No | { id, assignee, assignedBy, assignedAt, reason, endedAt, endReason, endedBy }; endReason is reassigned or unassigned |
infoRequests | ComplaintInfoRequestDto[] | No | { id, question, requestedBy, requestedAt, responseText, respondedAt, respondedBy, withdrawnAt, withdrawnReason, state }; state is open, answered or withdrawn |
resolutions | ComplaintResolutionDto[] | No | { id, summary, actionTaken, resolvedBy, resolvedAt, followUpDate, followUpNotes, supersededAt, supersededReason, isCurrent } |
attachments | ComplaintAttachmentDto[] | No | Every attachment the caller may see |
timeline | ComplaintEventDto[] | No | The newest 500 visible events, oldest first |
timelineTruncated | boolean | No | true when older events exist; page them with GET /events |
allowedActions | ComplaintAction[] | No | Exactly what this caller may do now |
access | { seesNotes, seesHistory, canRevealIdentity, isAssignee } | No |
Nested shapes:
| DTO | Fields |
|---|---|
ComplaintAttachmentDto | id, name (masked to attachment-<n>.<ext> for an unrevealed anonymous complainant's file), mimeType, extension, sizeBytes, visibility, eventId (the owning event's public id or null), uploadedBy (ComplaintActorDto), createdAt |
ComplaintEventDto | id (complaint_events.public_id), type, visibility, actor, fromValue, toValue, reason, body, attachments, createdAt |
ComplaintActorDto | userId (nullable), name (nullable), capacity (complainant, handler, administrator, system) |
ComplaintQuestionDto | key, label, type (text, textarea, number, date, time, select), required, optional options, optional helpText |
Which events a caller sees: public events always; internal_note with ComplaintInternalNotes_READ; identity_viewed with ComplaintIdentity_READ; every other internal event with ComplaintHistory_READ (eventVisibleTo, complaint-detail.mapper.ts:129). An internal attachment is visible only with ComplaintInternalNotes_READ.
6.3 Response: ComplaintEventPageDto
| Field | Type | Notes |
|---|---|---|
events | ComplaintEventDto[] | One page, oldest first |
nextBefore | string or null | Pass as before for the next, older page; null when there is none |
6.4 Response: ComplaintCommandResultDto and ComplaintPortalCommandResultDto
| Field | Type | Notes |
|---|---|---|
complaint | ComplaintDetailDto (admin) or ComplaintPortalDetailDto (portal) | The refreshed complaint as the caller now sees it. null when the act removed the caller's own visibility, for example an assignee who reassigned it away. On the admin surface it is also null when the read after commit fails: buildCommandResult logs the failure and still answers success with the eventId, because the write has committed |
eventId | string or null | Public id of the main history event the command wrote. Upload files against it with eventId on the attachment route. The portal's create, update and submit routes return null |
6.5 Response: ComplaintPortalListItemDto and ComplaintPortalDetailDto
ComplaintPortalListItemDto: id, reference (nullable; null for a draft), title, status, priority, category, relatedStudent (nullable), isAnonymous, submittedAt (nullable), lastActivityAt, dueAt (nullable), resolvedAt (nullable), awaitingYourResponse (an information request is waiting for this caller), createdAt, version, schoolTimezone.
ComplaintPortalDetailDto adds description, answers, questionSnapshot, incidentDate, incidentLocation, contactPreference, relatedStaff, gradeName, sectionName, academicSession, templateId, rejectionReason, cancellationReason, duplicateOfReference, infoRequests, resolutions, attachments, timeline, reopenDeadline, allowedActions. duplicateOfReference is always null: the portal never names another complaint.
| Field | Notes |
|---|---|
infoRequests | { id, question, requestedAt, responseText, respondedAt, state } with no internal actor detail |
resolutions | { summary, actionTaken, resolvedAt, followUpDate, isCurrent }; followUpNotes is never included |
timeline | ComplaintPortalEventDto[]: { id, type, actor, body, reason, toValue, attachments, createdAt }, where actor is you or school and never a user id. reason appears only for rejected, cancelled, reopened and withdrawn-request events. Internal events are absent |
attachments | Public attachments only |
reopenDeadline | The last moment the complainant may reopen, when reopening is allowed; otherwise null |
allowedActions | A subset of PORTAL_COMPLAINT_ACTIONS: update_draft, discard_draft, submit, add_attachment, respond_to_information_request, confirm_resolution, reopen_by_complainant, cancel |
6.6 Response: ComplaintFormOptionsDto
| Field | Type | Notes |
|---|---|---|
submitterKind | ComplaintSubmitterKind | Who the caller files as |
categories | { publicId, name, description, defaultPriority, requiresAttachment, allowAnonymous, questions }[] | Active, live categories offered to the caller's audience |
templates | { publicId, name, categoryId, titleHint, descriptionGuidance, defaultPriority, requiredFields }[] | |
settings | { anonymousEnabled, complainantCanSetPriority, maxAttachmentSizeMb, maxAttachments, allowedExtensions, complainantCanCancel, complainantCanReopen, reopenWindowDays } | |
priorities | { priority, label, description }[] | |
children | ComplaintPortalStudentOptionDto[] | Guardian only: the caller's own children |
self | ComplaintPortalStudentOptionDto or null | Student only: the caller's own placement |
canNameStudents | boolean | Whether the caller may name a student at all: self, a child, or via Students_READ |
schoolTimezone | string | IANA zone; render every date in it |
ComplaintPortalStudentOptionDto is { id, name, studentCode, gradeName, sectionName } (id is students.id). ComplaintPortalStaffOptionDto is { id, name, designation } (id is staff.id).
6.7 Response: statistics, bulk, picker and identity DTOs
| DTO | Fields |
|---|---|
ComplaintStatisticsDto | byStatus (ten keys, every status except draft), total, new, open, overdue, urgent, unassigned, assignedToMe, session (nullable; the session requested by academicSessionId, otherwise the session that contains the school's today, otherwise the one flagged current), thisWeek (from school-local Sunday 00:00) and thisMonth (from the first of the Bikram Sambat month), both calendar windows that ignore the session, thisSession, averageResolutionHours (nullable), resolvedCount, byCategory ({ key, label, count }[]), byPriority, bySubmitterKind (student, guardian, teacher, staff, admin, anonymous), byClass ({ classId, gradeName, sectionName, shift, count }[], where classId is the class publicId), automationLastRunAt (nullable; when the sweep last finished) |
ComplaintBulkResultDto | results ({ id, ok, errorCode?, message?, version? }[]), succeeded, failed |
ComplaintAssignableUserDto | userId, name, designation (nullable) |
ComplaintIdentityDto | userId, name, kind, email (nullable), phone (nullable), contactPreference (nullable), attachmentNames ({ id, name }[] with the real file names) |
6.8 Request: CreateMyComplaintDto and UpdateMyComplaintDto (portal)
Both extend one filing base, so an update sends the full filing again.
| Field | Type | Required | Validation and behaviour |
|---|---|---|---|
submissionId | uuid | Create only | Idempotency key per submitter. A replay returns the caller's existing complaint with message Already saved. |
submit | boolean | No (create only), default false | true submits in the same request |
expectedVersion | integer | Update only | |
title | string | Yes | 3-200 characters. After normalisation it must still be 3-200. Normalisation removes invisible characters (zero-width and bidirectional controls, the BOM, variation selectors U+FE00-U+FE0F, Hangul and other fillers, and the tag block U+E0000-U+E007F), replaces control characters with a space on one-line fields, and collapses whitespace |
description | string | No | At most 10000; at submission at least 10 characters after trimming |
categoryId | uuid | Yes | The category's publicId |
templateId | uuid | No | Must belong to that category |
priority | ComplaintPriority | No | Accepted only when complainantCanSetPriority is on, and refused with 400 COMPLAINT_PRIORITY_NOT_SELECTABLE otherwise (a caller filing as admin may always send it). Left out, the template's or category's default applies |
isAnonymous | boolean | No, default false | |
relatedStudentId | uuid | No | students.id. A student always names themselves (the value, if sent, must be their own row), unless anonymous. A guardian may name only their own children. Teachers, staff and administrators need Students_READ |
relatedStaffId | uuid | No | staff.id |
incidentDate | YYYY-MM-DD (strict ISO-8601) | No | Not after the school-local today |
incidentLocation | string | No | At most 200 |
contactPreference | string | No | At most 500 |
answers | object | No | Keyed by the category's question keys; free-text answers are normalised like description (invisible characters and C0 controls other than tab, LF and CR removed) |
6.9 Request: RecordComplaintDto (POST /api/complaints)
| Field | Type | Required | Validation and behaviour |
|---|---|---|---|
submissionId | uuid | Yes | A replay returns the existing complaint with HTTP 200 instead of 201 |
title | string | Yes | DTO allows 300; the filing rules then require 3-200 after normalisation |
description | string | Yes | DTO allows 10000; at least 10 after trimming |
categoryId | uuid | Yes | |
templateId | uuid | No | |
priority | ComplaintPriority | No | Always honoured for an office record |
relatedStudentId, relatedStaffId | uuid | No | |
incidentDate | ISO date string | No | Not in the future |
incidentLocation | string | No | DTO allows 250; filing rules cap at 200 |
contactPreference | string | No | DTO allows 550; filing rules cap at 500 |
answers | object | No | |
complainant | { kind, profileId } | No | kind is student, guardian or staff; profileId is that table's id. Absent means the office itself is the complainant (submitterKind: admin) |
A recorded complaint is never anonymous: the DTO has no isAnonymous field. The complainant must have a live profile and a live, unbanned account that can log in, or the request fails with 422 COMPLAINT_COMPLAINANT_INVALID.
6.10 Request: workflow action DTOs
Every class extends ExpectedVersionDto (expectedVersion: integer, required) except RevealIdentityDto.
| DTO | Route | Extra fields |
|---|---|---|
AcknowledgeComplaintDto | acknowledge | note? (at most 2000) |
AssignComplaintDto | assign | assigneeUserId (uuid), reason? (at most 1000) |
ReassignComplaintDto | reassign | assigneeUserId (uuid), reason (3-1000, required) |
UnassignComplaintDto | unassign | reason (3-1000, required) |
ExpectedVersionDto | start, archive, unarchive | None |
AddResponseDto | responses | body (1-10000) |
AddNoteDto | notes | body (1-10000) |
RequestInformationDto | information-requests | question (3-5000) |
WithdrawInformationRequestDto | .../withdraw | reason (3-1000) |
ChangePriorityDto | priority | priority, reason? (at most 1000) |
ChangeCategoryDto | category | categoryId (uuid, the category publicId), reason? (at most 1000) |
SetDueDateDto | due-date | dueAt (ISO date-time, or null to restore the computed deadline), reason (3-1000, required) |
ResolveComplaintDto | resolve | summary (3-5000), actionTaken (3-5000), followUpDate? (ISO date), followUpNotes? (at most 2000; internal) |
CloseComplaintDto | close | note? (at most 2000), overrideReason? (3-2000) |
ReopenComplaintDto | reopen | reason (3-2000) |
RejectComplaintDto | reject | reason (3-2000) |
MarkDuplicateComplaintDto | mark-duplicate | duplicateOfId (uuid), reason? (3-2000; defaults to the fixed text Marked as a duplicate of another complaint.) |
CancelComplaintDto | cancel | reason (3-2000) |
EscalateComplaintDto | escalate | reason (3-2000), recipientUserIds? (at most 20 uuids) |
RevealIdentityDto | identity-reveal | reason (3-1000). No expectedVersion: the reveal is a read and does not bump the version |
6.11 Request: bulk DTOs
Every bulk body has items: { id: uuid, expectedVersion: integer }[] with 1-100 items. The service refuses 101 or more with 400 COMPLAINT_BULK_LIMIT_EXCEEDED; the DTO carries a hard ceiling of 1000 (ArrayMaxSize(1000)), above which the body fails validation with 400 VALIDATION_FAILED before any item is validated further. A repeated id answers 400 VALIDATION_FAILED.
| DTO | Route | Extra fields |
|---|---|---|
BulkAcknowledgeDto | acknowledge | note? (at most 2000) |
BulkAssignDto | assign | assigneeUserId, reason? (at most 1000) |
BulkPriorityDto | priority | priority, reason? (at most 1000) |
BulkCategoryDto | category | categoryId, reason? (at most 1000) |
BulkCloseDto | close | note? (at most 2000), overrideReason? (3-2000) |
BulkReasonDto | reject, cancel | reason (3-2000) |
BulkArchiveDto | archive | None |
6.12 Request: attachment uploads (multipart/form-data)
| DTO | Field | Required | Notes |
|---|---|---|---|
ComplaintAttachmentUploadDto (admin) | file | Yes | Binary |
visibility | When eventId is absent | public needs ComplaintResponses_CREATE, internal needs ComplaintInternalNotes_CREATE; missing answers 400 VALIDATION_FAILED, a missing code 403 AUTH_FORBIDDEN | |
eventId | No | A public_response, internal_note or resolved event on this complaint, written by the caller within the last 30 minutes. The file takes that event's visibility; a conflicting visibility answers 400 COMPLAINT_ATTACHMENT_VISIBILITY_MISMATCH | |
ComplaintPortalAttachmentUploadDto | file | Yes | Binary |
eventId | No | An information_provided or reopened event on this complaint, written by the caller within the last 30 minutes. Without it, uploads are allowed only while the complaint is a draft |
6.13 Request: query DTOs
ListComplaintsQueryDto (both admin lists) extends QueryDto without search; sending search is refused by forbidNonWhitelisted.
| Field | Type | Default | Notes |
|---|---|---|---|
page, size, order, pagination | from QueryDto | page 1, size 20 (max 100) | pagination=false answers 400 PAGINATION_LIMIT_INVALID |
view | all, unassigned, overdue, urgent, recent, archived | all | all excludes archived rows; unassigned is submitted, acknowledged or reopened with no assignee; overdue is an open status past dueAt; urgent is an active status at urgent; recent is unarchived activity in the last 7 days; archived is archived rows only. On assigned-to-me every view except archived behaves as all |
status | ComplaintStatus[] | ||
categoryId | uuid[] | Category publicIds | |
priority | ComplaintPriority[] | ||
submitterKind | submitter kinds plus anonymous | ||
academicSessionId | uuid | Session publicId | |
gradeName, sectionName | string | At most 80 each | |
relatedStudentId, assigneeUserId | uuid | ||
submittedFrom, submittedTo | ISO date | Filter on submittedAt | |
overdue, reopened, anonymous, hasAttachments | boolean | Accept true or 1 | |
q | string | At most 100. Matches title, description, student name or code, subject staff name, assignee name, the submitter's name only on non-anonymous complaints, and the reference. A value that reads as a whole reference (CMP-7K3QX9MB, cmp7k3qx9mb or the bare eight characters, with I/L read as 1 and O as 0) matches it exactly; any other value also matches references containing it | |
sort | newest, oldest, priority, dueDate, updated, status, category | newest | Every sort appends id as a tie-breaker |
| DTO | Fields |
|---|---|
ListComplaintEventsQueryDto | before? (an event public id), limit (1-200, default 100) |
ComplaintStatisticsQueryDto | academicSessionId? (session publicId) |
ComplaintAssignableUsersQueryDto | q? (at most 100), limit (1-20, default 20) |
ListMyComplaintsQueryDto | QueryDto fields plus filter (all, open, waiting, resolved, closed, reopened, drafts; default all; open excludes waiting_for_information), relatedStudentId?, q? (at most 100), sort (newest, oldest, updated; default updated). pagination=false answers 400 PAGINATION_LIMIT_INVALID |
ComplaintPortalSearchQueryDto | q (2-100, required) |
ListComplaintCategoriesQueryDto | QueryDto fields plus q?, isActive?, includeDeleted? (default false), audience? |
ListComplaintTemplatesQueryDto | QueryDto fields plus q?, isActive?, includeDeleted? (default false), categoryId? |
ListComplaintEscalationRulesQueryDto | includeDeleted? only. The list is not paginated and returns at most UNPAGINATED_HARD_CAP (1000) rules; page, size, pagination or q is refused with 400 VALIDATION_FAILED |
ComplaintVersionQueryDto | expectedVersion (integer, at least 0), sent as a query parameter on config DELETE routes |
6.14 Request: configuration DTOs
Every update DTO makes its fields optional and requires expectedVersion (integer, at least 0). A stale version answers 409 COMPLAINT_CONFIG_VERSION_CONFLICT.
| DTO | Fields |
|---|---|
CreateComplaintCategoryDto | name (at most 80; the service also requires 1-80 after trimming), description? (at most 1000), notes? (at most 2000), isActive? (default true), applicableAudiences (non-empty, unique subset of student, guardian, staff), defaultPriority, ackTargetMinutes? and resolutionTargetMinutes? (1-525600, nullable), requiresAttachment? and allowAnonymous? (default false), questions? ({ key, label, type, required, options?, helpText? }[]; the service validates shape and answers 400 COMPLAINT_CATEGORY_QUESTIONS_INVALID), sortOrder? (0-10000) |
CreateComplaintTemplateDto | name (at most 80), categoryId (uuid), titleHint? (at most 200), descriptionGuidance? (at most 2000), defaultPriority? (nullable), requiredFields? (unique subset of incident_date, incident_location, related_student, related_staff, attachments), ackTargetMinutes?, resolutionTargetMinutes? (1-525600), isActive? (default true), sortOrder? (0-10000, default 0) |
CreateComplaintSlaRuleDto | categoryId (uuid), priority, ackTargetMinutes, resolutionTargetMinutes (1-525600 each). The update DTO changes only the two targets |
CreateComplaintEscalationRuleDto | name (at most 80), isActive?, trigger, afterMinutes (1-525600), categoryId? and priority? (null means any), notifyAssigneeManager, notifyTriage, recipientUserIds (at most 20, unique) |
UpdateComplaintPrioritySettingDto | label? (at most 40), description? (at most 500), ackTargetMinutes?, resolutionTargetMinutes? (1-525600), alertOnSubmit? |
UpdateComplaintSettingsDto | anonymousEnabled?, complainantCanSetPriority?, closureMode?, autoCloseAfterHours? (1-8760), complainantCanReopen?, reopenWindowDays? (1-365), complainantCanCancel?, maxAttachmentSizeMb? (1-25), maxAttachments? (1-10), maxTotalAttachments? (1-100, and at least maxAttachments by the table CHECK), dailySubmissionLimit? (1-100), allowedExtensions? (non-empty, unique subset of the ten extensions) |
UpdateComplaintReminderSettingDto | isEnabled?, afterHours? (0-8760), repeatHours? (1-720), maxReminders? (1-20) |
UpdateComplaintNotificationSettingDto | isEnabled?, channels? (unique subset of in_app, push, email, sms; the table requires at least one channel while enabled) |
ComplaintVersionBodyDto | expectedVersion, sent as the body of config restore routes |
The {priority}, {kind} and {eventKey} path parameters address closed, seeded sets. A value outside the set answers 400 VALIDATION_FAILED before any query runs.
7. Enum Reference
| Enum | Value | Meaning | Runtime Effect | Source |
|---|---|---|---|---|
ComplaintStatus | draft | Unsubmitted; only its author sees it | No reference, no deadline, deletable | schema/school/complaints.ts |
submitted | Filed, awaiting acknowledgement | Open; counts toward overdue | ||
acknowledged | Triage has seen it | Open | ||
assigned | Has a current assignee | Open; requires an assignee | ||
in_progress | The assignee is working on it | Open | ||
waiting_for_information | An information request is open | SLA clock paused; never overdue | ||
resolved | A current resolution exists | Awaits closure per closureMode | ||
closed | Finished | Terminal; reopenable | ||
reopened | Reopened after an outcome | Open; new SLA cycle | ||
rejected | Rejected or marked duplicate | Terminal | ||
cancelled | Withdrawn | Terminal | ||
ComplaintPriority | low, medium, high, urgent | Declaration order is sort order | sort=priority puts urgent first; alertOnSubmit on a level switches the triage alert to complaint.urgent_submitted | |
ComplaintSubmitterKind | student, guardian, teacher, staff, admin | Who filed | admin may not be anonymous | |
ComplaintDueSource | priority, category, sla_rule, template, manual | Where the deadline came from | manual survives priority and category changes | |
ComplaintClosureMode | manual | Staff close a resolved complaint | ||
automatic | The sweep closes resolutions made under this mode after autoCloseAfterHours | |||
complainant_confirmation | The complainant confirms | Staff closing needs overrideReason | ||
ComplaintEscalationTrigger | unacknowledged, unassigned, no_response, unresolved, awaiting_information | What an escalation rule watches | A rule with only notifyAssigneeManager is refused for unacknowledged and unassigned | |
ComplaintEventVisibility | public, internal | Who may read an event or attachment | ||
ComplaintEventType | submitted, acknowledged, category_changed, priority_changed, due_date_changed, assigned, reassigned, unassigned, investigation_started, public_response, internal_note, information_requested, information_provided, information_request_withdrawn, attachment_added, escalated, overdue, resolved, closed, reopened, rejected, marked_duplicate, cancelled, archived, unarchived, identity_viewed | The 26 history event types | Drive the timeline | |
ComplaintActorCapacity | complainant, handler, administrator, system | The capacity an event was written in | handler when the actor is the current assignee, otherwise administrator; system for the sweep | |
ComplaintAudience | student, guardian, staff | Who a category is offered to | Teachers file under staff | |
| Attachment extension | jpg, jpeg, png, webp, pdf, doc, docx, xls, xlsx, txt | The allowable file types | A school may narrow them in settings | |
| Template required field | incident_date, incident_location, related_student, related_staff, attachments | What a template demands at submission | related_student yields to anonymity | |
| Reminder kind | unacknowledged, unassigned, pending_information, approaching_due, overdue, pending_closure, reopened | Sweep reminder rows | All seeded disabled | |
| Notification event key | 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 | The 20 switchable events | Disabled or channel-less events send nothing | |
ComplaintAction | update_draft, discard_draft, submit, acknowledge, assign, reassign, unassign, start_investigation, add_response, add_note, add_attachment, request_information, withdraw_information_request, respond_to_information_request, change_priority, change_category, set_due_date, resolve, close, confirm_resolution, reopen, reopen_by_complainant, reject, mark_duplicate, cancel, escalate, archive, unarchive, reveal_identity | Every lifecycle action | Appears in allowedActions | utils/complaints/complaint-lifecycle.util.ts |
8. Endpoint Reference
Endpoints are grouped by family. Every family has one fully worked endpoint; the rest of the family differs only where stated. §13.1 lists every route individually.
Status codes: NestJS answers 201 to every POST in this module (no handler sets @HttpCode), except POST /api/complaints, which answers 200 on a replayed submissionId. GET, PATCH and DELETE answer 200.
8.1 GET /api/complaints
Purpose
The office register. The panel calls it for the complaints list with its views (all, unassigned, overdue, urgent, recent, archived), filters and search. It returns every non-draft complaint the caller may see, minus complaints the caller filed and complaints about the caller. Anonymous submitters are masked in every row. Use GET /api/complaints/assigned-to-me for a handler's own queue.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/complaints/register/complaints.controller.ts (findAll) |
| DTO | register/dto/list-complaints-query.dto.ts, shared/dto/complaint-response.dto.ts |
| Service | register/complaint-list.service.ts, register/complaint-list-query.ts, shared/complaint-access.service.ts (listVisibilityWhere) |
| Schema | packages/db/src/schema/school/complaints.ts |
| Tests | __tests__/complaint-register.integration.spec.ts, __tests__/complaint-privacy.integration.spec.ts |
Auth and Permissions
- Auth: JWT.
- Guard chain:
JwtAuthGuard,RoleGuard. - Permission:
Complaints_READ.assigned-to-meneedsComplaintAssignments_READand returns only rows whereassigned_to_user_idis the caller. - Guest support: None.
- Rate limit: None specific to this module.
- Idempotency: Read-only.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <token> |
| Params | No | |
| Query | No | ListComplaintsQueryDto (§6.13) |
| Body | No |
Response
{
"message": "Complaints fetched.",
"data": [
{
"id": "0192f0c1-6c2e-7a55-9d7e-3f1a2b4c5d6e",
"reference": "CMP-7K3QX9MB",
"title": "Broken bench in Room 4",
"status": "assigned",
"priority": "medium",
"category": { "publicId": "0192f0c1-0000-7000-8000-000000000009", "name": "Facilities", "isActive": true, "isDeleted": false },
"submitter": { "userId": null, "displayName": null, "kind": null, "isAnonymous": true },
"relatedStudent": null,
"gradeName": null,
"sectionName": null,
"academicSession": { "publicId": "0192f0c1-0000-7000-8000-00000000a001", "name": "2083" },
"assignee": { "userId": "0192f0c1-0000-7000-8000-00000000b001", "name": "Sita Sharma", "isActive": true },
"submittedAt": "2026-09-20T04:15:00.000Z",
"ackDueAt": "2026-09-21T04:15:00.000Z",
"dueAt": "2026-09-25T04:15:00.000Z",
"isOverdue": false,
"lastActivityAt": "2026-09-21T06:02:11.000Z",
"resolvedAt": null,
"closedAt": null,
"hasAttachments": true,
"escalationLevel": 0,
"reopenCount": 0,
"archivedAt": null,
"version": 4,
"schoolTimezone": "Asia/Kathmandu"
}
],
"errorCode": null,
"count": 1,
"currentPage": 1,
"totalPage": 1
}An empty result is data: [] with count: 0.
Side Effects
None. No cache is read or written.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | VALIDATION_FAILED | A query field fails its validator, or search is sent | Fix the query | ValidationPipe |
400 | PAGINATION_LIMIT_INVALID | pagination=false | The register is always paginated | complaint-list.service.ts |
401 | No or invalid token | Log in | JwtAuthGuard | |
403 | The active role lacks Complaints_READ | Not permitted | RoleGuard |
Edge Cases
- A draft never appears. Neither does a complaint the caller filed or one whose
relatedStaffIdis the caller's own staff row. A superadmin sees every non-draft complaint. qmatches the submitter's name only on non-anonymous complaints, so a search cannot find an anonymous complainant by name.view=allexcludes archived complaints;view=archivedreturns only them.submittedFromandsubmittedTofiltersubmittedAt.
Example Requests
GET /api/complaints?view=overdue&priority=high&priority=urgent&sort=dueDate&page=1&size=20 HTTP/1.1
Authorization: Bearer TOKENcurl "$API_URL/api/complaints?view=unassigned&q=CMP-7K3QX9MB" \
-H "Authorization: Bearer TOKEN"8.2 POST /api/complaints (office record)
Records a complaint the office received on paper, by phone or in person. The complaint is inserted and submitted in one transaction, so it never exists as a draft and the filing rules are the same ones the portal applies. With complainant, the named person becomes the submitter and recordedByUserId names the recorder. Without it, the recorder is the submitter with kind admin.
- Permission:
Complaints_CREATE. - Body:
RecordComplaintDto(§6.9). - Response:
ComplaintCommandResultDto,201on first write and200on a replayedsubmissionId. - Side effects:
complaintsrow (withreferenceNofromcomplaint_reference_seq), asubmittedevent with capacityadministratorand metadatarecorded: true, and two notifications through the outbox:submitted_complainantandsubmitted_triage(kindcomplaint.urgent_submittedwhen the priority level hasalertOnSubmit, otherwisecomplaint.new_for_triage).
curl -X POST "$API_URL/api/complaints" \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{
"submissionId": "0192f0c1-1111-7000-8000-000000000001",
"title": "Bus 3 left before pickup time",
"description": "The guardian reports the bus left the stop five minutes early on Monday.",
"categoryId": "0192f0c1-0000-7000-8000-00000000000a",
"priority": "high",
"incidentDate": "2026-09-21",
"answers": { "route": "Route 3" },
"complainant": { "kind": "guardian", "profileId": "0192f0c1-2222-7000-8000-000000000001" }
}'| HTTP Status | Error Code | Condition |
|---|---|---|
400 | VALIDATION_FAILED | DTO failure, or the title, description, location or contact preference fails the normalised length rules |
400 | COMPLAINT_CATEGORY_INACTIVE, COMPLAINT_CATEGORY_NOT_APPLICABLE | The category is deactivated, or not offered to the complainant's audience |
400 | COMPLAINT_TEMPLATE_INACTIVE, COMPLAINT_TEMPLATE_CATEGORY_MISMATCH | The template is deactivated or belongs to another category |
400 | COMPLAINT_REQUIRED_FIELD_MISSING | A template's required field is empty |
400 | COMPLAINT_ANSWERS_INVALID | An answer does not fit the category's questions |
400 | COMPLAINT_ATTACHMENT_REQUIRED | The category or template requires a file; an office record has none at submission |
400 | COMPLAINT_INCIDENT_DATE_IN_FUTURE | incidentDate is after the school-local today |
404 | COMPLAINT_CATEGORY_NOT_FOUND, COMPLAINT_TEMPLATE_NOT_FOUND | Unknown publicId |
404 | COMPLAINT_RELATED_STUDENT_NOT_FOUND, COMPLAINT_RELATED_STAFF_NOT_FOUND | No live row for the id |
409 | COMPLAINT_VERSION_CONFLICT | The insert on submissionId conflicted but no complaint of this submitter carries that id, so there is nothing to replay |
422 | COMPLAINT_COMPLAINANT_INVALID | No live profile of that kind, or the account is deleted, banned or cannot log in |
422 | COMPLAINT_COMPLAINANT_IS_RECORDER | The named complainant is the recorder's own account; a person's own complaint is filed from My complaints |
When the office records without complainant, the recorder is both submitter and recorder, with submitter kind admin. Nobody complained in person, so the recorder is not treated as a complainant: the register shows the complaint to them like any other handler holding Complaints_READ, and the response carries it. The portal does not list it, because it hides what a caller recorded themselves.
8.3 GET /api/complaints/{id} and GET /api/complaints/{id}/events
findOne returns ComplaintDetailDto. For a caller who is not the complainant (every reader except the submitter, and every reader of an office record with nobody named) it also writes a best-effort ComplaintAudit / READ activity entry for the complaint; a failure to write it is logged and never fails the read.
listEvents pages the visible history newest-to-oldest and returns each page oldest first (ComplaintEventPageDto). limit is 1-200 (default 100). Pass the previous page's nextBefore as before.
- Permission: none declared; allowlisted, with visibility resolved per complaint (§5).
- Errors:
403 AUTH_ACTIVE_ROLE_REQUIRED(no active role),404 COMPLAINT_NOT_FOUND(missing, draft, or invisible; the three are indistinguishable),400 COMPLAINT_EVENT_CURSOR_INVALID(beforeis not an event of this complaint visible to the caller).
curl "$API_URL/api/complaints/$ID/events?limit=50&before=$NEXT_BEFORE" \
-H "Authorization: Bearer TOKEN"8.4 POST /api/complaints/{id}/assign (workflow action family)
Purpose
Every lifecycle action is its own POST route under /api/complaints/{id}. Assign gives an unassigned complaint to an eligible handler. The panel renders a button only when assign is in allowedActions, but the server re-checks everything. The same contract applies to acknowledge, reassign, unassign, start, responses, notes, information requests, withdraw, priority, category, due-date, resolve, close, reopen, reject, mark-duplicate, cancel, escalate, archive and unarchive.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | workflow/complaint-actions.controller.ts, workflow/complaint-outcome-actions.controller.ts |
| DTO | workflow/dto/complaint-action.dto.ts |
| Service | workflow/complaint-workflow.service.ts delegating to complaint-triage.commands.ts, complaint-handling.commands.ts, complaint-outcome.commands.ts; all run through ComplaintCommandBase.execute |
| Schema | complaints, complaint_assignments, complaint_events |
| Tests | __tests__/complaint-workflow.integration.spec.ts |
Auth and Permissions
- Auth: JWT.
- Guard chain:
JwtAuthGuard,RoleGuard, then visibility inside the transaction. - Permission:
ComplaintAssignments_CREATE, plus visibility. - Guest support: None.
- Rate limit: None.
- Idempotency:
expectedVersion. A retried request after success fails with409 COMPLAINT_VERSION_CONFLICTbecause the version moved.
Request
{
"assigneeUserId": "0192f0c1-0000-7000-8000-00000000b001",
"reason": "Handles transport issues",
"expectedVersion": 3
}Minimal: {"assigneeUserId": "...", "expectedVersion": 3}.
Response
{
"message": "Complaint assigned.",
"data": {
"complaint": { "id": "0192f0c1-6c2e-7a55-9d7e-3f1a2b4c5d6e", "status": "assigned", "version": 4, "allowedActions": ["reassign", "unassign", "add_response", "add_note", "add_attachment", "request_information", "change_priority", "change_category", "set_due_date", "resolve", "reject", "mark_duplicate", "cancel", "escalate"] },
"eventId": "0192f0c2-9f00-7000-8000-000000000123"
},
"errorCode": null
}The complaint object is the full ComplaintDetailDto, abbreviated here. Each handler sets its own message (for example Complaint assigned., Complaint acknowledged.).
Side Effects
- Locks the complaint row (
SELECT ... FOR NO KEY UPDATEinsideresolveVisiblewithlock: true) for the whole transaction. - If the complaint was never acknowledged, writes
acknowledgedAtand anacknowledgedevent first. - Inserts a
complaint_assignmentsrow, setsassigned_to_user_idandassigned_at, moves the status toassigned, bumpsversion. - Appends an
assignedevent. - Writes notifications
assigned_assignee(to the assignee) andassigned_complainant(seeded disabled) throughNotificationService.send, which writesnotification_eventand outbox rows in the same transaction. The actor is never notified of their own act. - The deferred consistency triggers check at commit that
assigned_to_user_idmatches the one active assignment.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | VALIDATION_FAILED | Body fails its DTO | Fix the request | ValidationPipe |
403 | Missing ComplaintAssignments_CREATE | Not permitted | RoleGuard | |
404 | COMPLAINT_NOT_FOUND | Missing, draft or invisible | Refresh the list | ComplaintAccessService |
409 | COMPLAINT_ARCHIVED | The complaint is archived | Unarchive first | assertCanPerform |
409 | COMPLAINT_INVALID_TRANSITION | Status is not submitted, acknowledged or reopened | Reload; someone moved it | assertCanPerform |
409 | COMPLAINT_VERSION_CONFLICT | expectedVersion is stale | Reload and retry | assertVersion |
409 | COMPLAINT_ALREADY_ASSIGNED | An active assignment exists | Use reassign | complaint-triage.commands.ts |
422 | COMPLAINT_ASSIGNEE_INELIGIBLE | The user is the subject, a guardian of the related student, not live, or holds neither ComplaintAssignments_READ nor Complaints_READ; on a non-anonymous complaint also the submitter | Pick someone else | ComplaintPeopleService.isEligibleAssignee |
Edge Cases
- Every ineligibility reason answers the same code. On an anonymous complaint the submitter check is skipped, so assigning the submitter succeeds rather than refusing, and a refusal cannot reveal who filed it. The submitter still cannot see the complaint on the admin surface, because visibility excludes the submitter.
- Two concurrent assigns: the second waits on the row lock, then fails the version check.
- A
reopenedcomplaint can be assigned only after its previous assignment ended; otherwise use reassign, which moves it toassigned.
Example Requests
POST /api/complaints/0192f0c1-6c2e-7a55-9d7e-3f1a2b4c5d6e/assign HTTP/1.1
Authorization: Bearer TOKEN
Content-Type: application/jsoncurl -X POST "$API_URL/api/complaints/$ID/assign" \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"assigneeUserId":"0192f0c1-0000-7000-8000-00000000b001","expectedVersion":3}'8.5 The other workflow actions
Each row adds to the §8.4 contract (visibility, archived, transition, version). "From" is the status set ACTION_FROM allows; "To" is the result.
| Route | From | To | Writes | History event | Notification key | Extra errors |
|---|---|---|---|---|---|---|
acknowledge | submitted | acknowledged | acknowledgedAt | acknowledged | acknowledged_complainant | |
reassign | assigned, in_progress, waiting_for_information, reopened | assigned from reopened, otherwise unchanged | Ends the active assignment (reassigned), inserts a new one | reassigned | assigned_assignee, reassigned_previous_assignee, assigned_complainant | 409 COMPLAINT_NOT_ASSIGNED, 409 COMPLAINT_SAME_ASSIGNEE, 422 COMPLAINT_ASSIGNEE_INELIGIBLE |
unassign | assigned, reopened | acknowledged from assigned, otherwise unchanged | Ends the active assignment (unassigned), clears the assignee | unassigned | reassigned_previous_assignee | 409 COMPLAINT_NOT_ASSIGNED |
start | assigned, reopened | in_progress | Status | investigation_started | 403 COMPLAINT_NOT_CURRENT_ASSIGNEE when the caller is not the assignee (checked before the transition) | |
responses | active statuses | in_progress when the caller is the assignee and the status is assigned or reopened, otherwise unchanged | Sets firstResponseAt if unset | public_response (and investigation_started when it moves) | response_added_complainant | |
notes | active statuses, resolved, closed | as responses | At most one update_notice row per complaint, school-local day and SLA cycle in complaint_automation_log | internal_note | investigation_update_complainant (seeded disabled), sent only for the first note of the day; the note body is never sent | |
information-requests | assigned, in_progress, reopened | waiting_for_information | Inserts a request, sets slaPausedAt, firstResponseAt if unset | information_requested | information_requested_complainant | 409 COMPLAINT_NOT_ASSIGNED, 409 COMPLAINT_INFORMATION_REQUEST_OPEN |
.../{requestId}/withdraw | waiting_for_information | in_progress | Sets withdrawnAt, withdrawnReason; resumes the SLA clock | information_request_withdrawn | 404 COMPLAINT_INFORMATION_REQUEST_NOT_FOUND, 409 COMPLAINT_INVALID_TRANSITION if already answered or withdrawn | |
priority | active statuses | unchanged | Recomputes dueAt unless dueSource is manual; recomputes ackDueAt only while unacknowledged | priority_changed | 409 COMPLAINT_NO_CHANGE | |
category | active statuses | unchanged | As priority | category_changed | 404 COMPLAINT_CATEGORY_NOT_FOUND, 400 COMPLAINT_CATEGORY_INACTIVE, 409 COMPLAINT_NO_CHANGE | |
due-date | active statuses | unchanged | A date-time sets dueAt with dueSource: manual; null restores the computed deadline | due_date_changed | 400 COMPLAINT_DUE_DATE_INVALID (not in the future, or not after submission) | |
resolve | acknowledged, assigned, in_progress, waiting_for_information, reopened | resolved | Inserts a resolution carrying the current closureMode; withdraws an open request (reason Resolved) and resumes the clock | resolved | resolved_complainant | |
close | resolved | closed | closedAt | closed (metadata closureKind: manual or override) | closed_complainant | 409 COMPLAINT_CLOSURE_REQUIRES_OVERRIDE when closureMode is complainant_confirmation and overrideReason is absent |
reopen | resolved, closed, rejected, cancelled | reopened | Supersedes the current resolution; clears the resolved, closed, rejection, duplicate and cancellation fields; increments reopenCount; sets lastReopenedAt; resets the pause; recomputes dueAt from the reopen time | reopened | reopened_handlers (the assignee, or triage) | |
reject | active statuses | rejected | rejectedAt, rejectionReason; withdraws an open request (reason Rejected) | rejected | rejected_complainant | |
mark-duplicate | active statuses | rejected | As reject, plus duplicateOfComplaintId; from waiting_for_information it withdraws the open request and resumes the clock; an internal note on the target | marked_duplicate (its toValue, the target's reference, is blanked for callers who cannot open the target) | rejected_complainant | 400 COMPLAINT_DUPLICATE_TARGET_INVALID (self, invisible, draft, itself a duplicate, or the source already has duplicates) |
cancel | active statuses | cancelled | cancelledAt, cancellationReason; withdraws an open request and clears the pause | cancelled | cancelled_complainant | |
escalate | active statuses | unchanged | Increments escalationLevel, sets lastEscalatedAt | escalated (metadata manual: true) | escalated_recipients to the assignee's manager, triage and the named recipients | 422 COMPLAINT_ESCALATION_RECIPIENT_INVALID |
archive | closed, rejected, cancelled | unchanged | archivedAt | archived | 409 COMPLAINT_ARCHIVE_TOO_EARLY when a closed complaint is still inside the complainant's reopen window and reopening is on | |
unarchive | closed, rejected, cancelled (archived) | unchanged | Clears archivedAt | unarchived | 409 COMPLAINT_NOT_ARCHIVED |
mark-duplicate locks both complaints in ascending id order before checking either, so two opposing duplicate marks cannot deadlock.
8.6 POST /api/complaints/{id}/identity-reveal
Returns who filed an anonymous complaint. It is the only route that returns an anonymous submitter's identity, and every call writes an identity_viewed history event with the stated reason. It does not require or bump version.
- Permission:
ComplaintIdentity_READ(superadmin-only by default), plus visibility. It is allowed on an archived complaint. - Body:
{ "reason": "Safeguarding referral requires the reporter's contact" }(3-1000). - Response:
ComplaintIdentityDto(§6.7),201. - Errors:
404 COMPLAINT_NOT_FOUND,409 COMPLAINT_INVALID_TRANSITION(a draft),409 COMPLAINT_NOT_ANONYMOUS. - The
identity_viewedevent is visible only to holders ofComplaintIdentity_READ.
8.7 POST /api/complaint-bulk/{action}
Runs the same single-complaint command once per item, each in its own transaction, in request order. One item's failure never stops the batch; it is reported in results with its errorCode.
- Permission: the same code as the single action (§4).
- Body: §6.11.
- Response:
ComplaintBulkResultDto,201.
{
"message": "Bulk acknowledge complete.",
"data": {
"results": [
{ "id": "0192f0c1-6c2e-7a55-9d7e-3f1a2b4c5d6e", "ok": true, "version": 3 },
{ "id": "0192f0c1-6c2e-7a55-9d7e-3f1a2b4c5d6f", "ok": false, "errorCode": "COMPLAINT_VERSION_CONFLICT", "message": "..." }
],
"succeeded": 1,
"failed": 1
},
"errorCode": null
}Whole-request errors: 400 COMPLAINT_BULK_LIMIT_EXCEEDED (101-1000 items: split the batch), 400 VALIDATION_FAILED (more than 1000 items, a repeated id, or another DTO failure).
8.8 POST /api/complaints/{id}/attachments and GET /api/complaints/{id}/attachments/{attachmentId}
Upload
multipart/form-data with file, and visibility or eventId (§6.12). Permission: ComplaintAttachments_CREATE, plus visibility and the add_attachment transition (active statuses and resolved), plus ComplaintResponses_CREATE for a public file or ComplaintInternalNotes_CREATE for an internal one.
The file passes, in order: extension against allowedExtensions and the size limit, a magic-byte sniff that must agree with the extension, a structural scan in a worker pool, and for images a re-encode that strips EXIF (an image whose signature passes but whose bytes do not decode is refused with 400 COMPLAINT_ATTACHMENT_REJECTED and the message "This image could not be read. Save it again or attach a different file."). The service then hashes the stored bytes, records their size (the re-encoded size for an image), with SHA-256 and writes to private storage. Placement then runs inside the upload transaction on the locked complaint row: visibility, the event and visibility rules, the anonymous-type check on the portal, the per-message limit (maxAttachments files on one event, or among loose files) and the lifetime limit (maxTotalAttachments). A loose file writes its own attachment_added event. Response: ComplaintAttachmentDto, 201.
Multer writes each upload to uploads/complaint-attachment/<uuid>/, a fresh uuid per upload that identifies no one, and refuses a destination that resolves outside the upload root. The multer allowlist accepts application/msword and application/vnd.ms-excel for .doc and .xls. Storage keys are never logged beside a complaint id.
curl -X POST "$API_URL/api/complaints/$ID/attachments" \
-H "Authorization: Bearer TOKEN" \
-F "file=@bench-photo.jpg" \
-F "eventId=0192f0c2-9f00-7000-8000-000000000123"| HTTP Status | Error Code | Condition |
|---|---|---|
400 | COMPLAINT_ATTACHMENT_EMPTY | Zero-byte file |
400 | COMPLAINT_ATTACHMENT_TYPE_NOT_ALLOWED | Extension not allowed, sniff disagrees, or the structural scan finds a disallowed structure |
400 | COMPLAINT_ATTACHMENT_EVENT_INVALID | eventId is not an attachable event of this complaint written by the caller in the last 30 minutes |
400 | COMPLAINT_ATTACHMENT_VISIBILITY_MISMATCH | visibility conflicts with the event's |
422 | COMPLAINT_ATTACHMENT_ANONYMOUS_TYPE_REFUSED | Portal only: a type other than jpg, jpeg, png, webp or txt on an anonymous complaint |
400 | VALIDATION_FAILED | Neither visibility nor eventId |
403 | AUTH_FORBIDDEN | Missing the response or note code for the chosen visibility |
409 | COMPLAINT_ATTACHMENT_LIMIT_REACHED | Per-message or lifetime limit |
413 | COMPLAINT_ATTACHMENT_TOO_LARGE | Larger than maxAttachmentSizeMb, or larger than the multer ceiling |
422 | COMPLAINT_ATTACHMENT_REJECTED | The scan rejected the content |
503 | COMPLAINT_ATTACHMENT_SCAN_UNAVAILABLE, COMPLAINT_ATTACHMENT_STORAGE_UNAVAILABLE | Scanner or storage unavailable; retry later |
Download
The handler declares no @Permissions(), and its source comment says not to add one: a declared permission would take the handler off the allowlist path and refuse every assignee who lacks that code. role.guard.ts lists it in NO_PERMISSION_ADMIN_HANDLERS as ComplaintAttachmentsController.download, so RoleGuard passes it through. ComplaintAttachmentDownloadService.downloadForAdmin then 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 answers 403 AUTH_ACTIVE_ROLE_REQUIRED, and an invisible complaint 404 COMPLAINT_NOT_FOUND. The attachment must belong to that complaint, and an internal attachment also needs ComplaintInternalNotes_READ; otherwise the answer is 404 COMPLAINT_ATTACHMENT_NOT_FOUND. The response streams the bytes with the stored MIME type. The file name is masked to attachment-<n>.<ext> for an anonymous complainant's upload. Each download writes a best-effort ComplaintAudit / READ activity entry.
8.9 GET /api/complaint-assignable-users and GET /api/complaint-statistics
complaint-assignable-users feeds the assign, reassign, escalate and escalation-rule pickers. It returns live users holding ComplaintAssignments_READ or Complaints_READ, filtered by q on full name, at most limit (20). It is not filtered by any complaint, so a missing name cannot reveal an anonymous submitter. Access: any of the four codes in §5; 403 AUTH_ACTIVE_ROLE_REQUIRED without an active role.
complaint-statistics returns ComplaintStatisticsDto for the dashboard, optionally scoped to academicSessionId. Every count applies the register's visibility predicate (listVisibilityWhere), so complaints the caller filed, and complaints about the caller, are not counted for them. Permission: ComplaintStatistics_READ. automationLastRunAt reads the Redis key REDIS_KEYS.COMPLAINTS_SWEEP_LAST_SUCCESS.
8.10 Configuration families
Worked example: POST /api/complaint-categories
curl -X POST "$API_URL/api/complaint-categories" \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Library",
"applicableAudiences": ["student", "guardian"],
"defaultPriority": "low",
"resolutionTargetMinutes": 4320,
"questions": [
{ "key": "book", "label": "Book title", "type": "text", "required": false }
]
}'Response 201: ComplaintCategoryDto with publicId, version: 1 and isSystem: false.
| Family | Routes | Delete behaviour | Specific errors |
|---|---|---|---|
| Categories | list (paginated; pagination=false allowed up to 1000 rows; ordered by sortOrder, lower-cased name, id), get, create, update, delete, restore | Soft delete (deleted_at). A seeded isSystem category can be deactivated but never deleted. A category with a live template cannot be deleted | 404 COMPLAINT_CATEGORY_NOT_FOUND, 409 COMPLAINT_CATEGORY_NAME_ALREADY_EXISTS (case-insensitive among live rows), 409 COMPLAINT_CATEGORY_SYSTEM_PROTECTED, 409 COMPLAINT_CATEGORY_HAS_TEMPLATES, 400 COMPLAINT_CATEGORY_QUESTIONS_INVALID, 400 VALIDATION_FAILED (name outside 1-80 after trimming) |
| Templates | list (paginated as categories), get, create, update, delete, restore | Soft delete | 404 COMPLAINT_TEMPLATE_NOT_FOUND, 409 COMPLAINT_TEMPLATE_NAME_ALREADY_EXISTS, 404 COMPLAINT_CATEGORY_NOT_FOUND, 400 COMPLAINT_CATEGORY_INACTIVE |
| Priorities | list, update of the four fixed rows | No create or delete | 400 VALIDATION_FAILED for an unknown {priority} |
| SLA rules | list (unpaginated), create, update, delete | Hard delete; the complaint falls back to the category or priority targets | 404 COMPLAINT_SLA_RULE_NOT_FOUND, 409 COMPLAINT_SLA_RULE_ALREADY_EXISTS (one rule per category and priority), 404 COMPLAINT_CATEGORY_NOT_FOUND |
| Escalation rules | list (unpaginated, ordered by name; includeDeleted optional), create, update, delete, restore | Soft delete | 404 COMPLAINT_ESCALATION_RULE_NOT_FOUND, 422 COMPLAINT_ESCALATION_RECIPIENT_INVALID (an ineligible named recipient, or a manager-only rule on unacknowledged or unassigned, which have no assignee) |
| Settings | get, update, update one reminder kind | Singleton | 400 VALIDATION_FAILED for an unknown {kind} |
| Notification settings | list, update one event key | Seeded set | 400 VALIDATION_FAILED for an unknown {eventKey} |
Every update, delete and restore requires expectedVersion and answers 409 COMPLAINT_CONFIG_VERSION_CONFLICT when it is stale. Config deletes take it as a query parameter; restores take it in the body.
8.11 POST /api/mobile/complaints (portal filing family)
Purpose
The complainant starts a complaint. Without submit, it saves a draft the caller can edit, attach files to and submit later. With submit: true it files immediately. The route is idempotent on submissionId, so a client that retries after a timeout gets its own complaint back rather than a duplicate.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | portal/complaint-portal.controller.ts (create) |
| DTO | portal/dto/complaint-portal-request.dto.ts |
| Service | portal/complaint-portal-draft.service.ts, shared/complaint-draft.service.ts, shared/complaint-submission.service.ts, portal/complaint-portal-audience.service.ts |
| Schema | complaints, complaint_events |
| Tests | __tests__/complaint-portal.integration.spec.ts |
Auth and Permissions
- Auth: JWT with an active role.
- Guard chain:
JwtAuthGuard,RoleGuard. - Permission:
MyComplaints_CREATE. - Guest support: None.
- Rate limit: at most 20 open drafts per submitter; at most
dailySubmissionLimitsubmissions per submitter per school-local day. Both are counted under a per-submitter lock, after the replay check: a replayedsubmissionIdreturns the existing complaint before any limit is checked. - Idempotency:
submissionId.
Request
{
"submissionId": "0192f0c1-3333-7000-8000-000000000001",
"title": "Bullying at lunch",
"description": "An older pupil takes my food at lunch most days this week.",
"categoryId": "0192f0c1-0000-7000-8000-000000000004",
"isAnonymous": true,
"incidentDate": "2026-09-19",
"incidentLocation": "Canteen",
"submit": true
}Minimal draft: {"submissionId": "...", "title": "Bus timing", "categoryId": "..."}.
Response
201 with ComplaintPortalCommandResultDto; message is Complaint saved. or Already saved. on a replay. eventId is null.
{
"message": "Complaint saved.",
"data": {
"complaint": {
"id": "0192f0c1-4444-7000-8000-000000000001",
"reference": "CMP-D23PQPFK",
"title": "Bullying at lunch",
"status": "submitted",
"priority": "high",
"isAnonymous": true,
"relatedStudent": null,
"awaitingYourResponse": false,
"version": 2,
"schoolTimezone": "Asia/Kathmandu",
"allowedActions": ["cancel"]
},
"eventId": null
},
"errorCode": null
}Side Effects
- Inserts the draft row. With
submit, also issues the reference, snapshots the category questions and academic context, computesackDueAt,dueAtanddueSource, appends asubmittedevent, and sendssubmitted_complainantandsubmitted_triagethrough the outbox. - A student filing non-anonymously names themselves automatically; an anonymous student complaint names nobody.
Error Cases
| HTTP Status | Error Code | Condition | Client Action |
|---|---|---|---|
400 | VALIDATION_FAILED | DTO or normalised length failure | Fix input |
400 | COMPLAINT_ANONYMOUS_NOT_ALLOWED | Anonymity is off for the school or the category, or the filer is admin | Turn anonymity off |
400 | COMPLAINT_ANONYMOUS_STUDENT_NOT_ALLOWED | Anonymous and naming a student | Drop one of the two |
422 | COMPLAINT_ATTACHMENT_ANONYMOUS_TYPE_REFUSED | Submitting an anonymous complaint that carries a complainant-uploaded file other than jpg, jpeg, png, webp or txt | Remove the file, or file with your name |
400 | COMPLAINT_CATEGORY_INACTIVE, COMPLAINT_CATEGORY_NOT_APPLICABLE, COMPLAINT_TEMPLATE_INACTIVE, COMPLAINT_TEMPLATE_CATEGORY_MISMATCH, COMPLAINT_REQUIRED_FIELD_MISSING, COMPLAINT_ANSWERS_INVALID, COMPLAINT_ATTACHMENT_REQUIRED, COMPLAINT_INCIDENT_DATE_IN_FUTURE | Filing rules at submission (as §8.2) | Fix the filing |
403 | AUTH_ACTIVE_ROLE_REQUIRED | No active role | Choose a role |
403 | COMPLAINT_AUDIENCE_UNRESOLVED | A student or guardian role with no live profile row | Contact the school |
404 | COMPLAINT_RELATED_STUDENT_NOT_FOUND | Naming a student the caller may not name | Pick an allowed student |
404 | COMPLAINT_CATEGORY_NOT_FOUND, COMPLAINT_TEMPLATE_NOT_FOUND, COMPLAINT_RELATED_STAFF_NOT_FOUND | Unknown id | Refresh form options |
400 | COMPLAINT_PRIORITY_NOT_SELECTABLE | priority sent while settings.complainantCanSetPriority is off (not refused for a caller filing as admin) | Leave priority out |
409 | COMPLAINT_DRAFT_LIMIT_REACHED | 20 open drafts | Submit or discard one |
409 | COMPLAINT_VERSION_CONFLICT | The insert on submissionId conflicted and no complaint of this caller carries it | Retry with a new id |
429 | COMPLAINT_SUBMISSION_LIMIT_REACHED | Daily submission limit reached | Try tomorrow |
Edge Cases
- A replay with
submit: truedoes not submit a second time; it returns the existing complaint, and no draft or daily limit is checked for it. - A required attachment cannot be satisfied by
submit: trueon create, because files attach to a draft after it exists. Save a draft, upload, then call/submit.
Example Requests
curl -X POST "$API_URL/api/mobile/complaints" \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"submissionId":"0192f0c1-3333-7000-8000-000000000001","title":"Bus timing","categoryId":"0192f0c1-0000-7000-8000-00000000000a"}'8.12 The rest of the portal
| Route | Behaviour | Errors beyond the identity check (404 COMPLAINT_NOT_FOUND) |
|---|---|---|
GET /form-options | ComplaintFormOptionsDto for the caller's audience; an office administrator (no staff row) sees every live, active category | 403 AUTH_ACTIVE_ROLE_REQUIRED, 403 COMPLAINT_AUDIENCE_UNRESOLVED |
GET /student-options | Up to 20 live students by name or student code; empty unless the active role holds Students_READ | Same as above |
GET /staff-options | Up to 20 live staff by name, for every audience | Same as above |
GET / | The caller's own complaints including drafts, paginated (ListMyComplaintsQueryDto) | 400 PAGINATION_LIMIT_INVALID |
GET /{id} | ComplaintPortalDetailDto | |
PATCH /{id} | Replaces a draft's filing; needs expectedVersion. Saving it as anonymous is refused while a complainant-uploaded file of another type than jpg, jpeg, png, webp or txt is attached | 409 COMPLAINT_NOT_DRAFT, 409 COMPLAINT_VERSION_CONFLICT, 422 COMPLAINT_ATTACHMENT_ANONYMOUS_TYPE_REFUSED, 400 COMPLAINT_PRIORITY_NOT_SELECTABLE, filing-field errors |
DELETE /{id}?expectedVersion=n | Discards a draft and its files; stored files are deleted after commit, and a storage failure is logged only | 409 COMPLAINT_NOT_DRAFT, 409 COMPLAINT_VERSION_CONFLICT |
POST /{id}/submit | Submits a saved draft; body { expectedVersion }. Re-authorises the named student against the caller's current audience, and refuses when the active role's audience differs from the one the draft was filed under | 409 COMPLAINT_NOT_DRAFT, 409 COMPLAINT_SUBMITTER_KIND_CHANGED (switch back to the role the draft was filed in), 404 COMPLAINT_RELATED_STUDENT_NOT_FOUND, 429 COMPLAINT_SUBMISSION_LIMIT_REACHED, 422 COMPLAINT_ATTACHMENT_ANONYMOUS_TYPE_REFUSED, the filing-rule errors of §8.11 |
POST /{id}/information-requests/{requestId}/respond | Answers the open request (body { response, expectedVersion }, 1-10000); waiting_for_information to in_progress; resumes the SLA clock; event information_provided; notifies information_provided_assignee (the assignee, or triage when there is none) | 404 COMPLAINT_INFORMATION_REQUEST_NOT_FOUND (unknown, answered or withdrawn), 409 COMPLAINT_INVALID_TRANSITION, 409 COMPLAINT_VERSION_CONFLICT |
POST /{id}/confirm-resolution | resolved to closed; event closed with closureKind: complainant | 409 COMPLAINT_INVALID_TRANSITION, 409 COMPLAINT_VERSION_CONFLICT |
POST /{id}/reopen | resolved or closed to reopened, within reopenWindowDays of the current resolution; body { reason, expectedVersion } | 409 COMPLAINT_REOPEN_NOT_ALLOWED (setting off), 409 COMPLAINT_REOPEN_WINDOW_EXPIRED, 409 COMPLAINT_INVALID_TRANSITION |
POST /{id}/cancel | Active status to cancelled; notifies cancelled_handlers; body { reason, expectedVersion } | 409 COMPLAINT_CANCEL_NOT_ALLOWED (setting off), 409 COMPLAINT_INVALID_TRANSITION |
POST /{id}/attachments | As §8.8, always public, capacity complainant; without eventId only while a draft. On an anonymous complaint only jpg, jpeg, png, webp and txt are accepted: document formats carry the author's name in their own properties, while images are re-encoded (dropping metadata) and plain text has none | 400 COMPLAINT_ATTACHMENT_EVENT_INVALID, 422 COMPLAINT_ATTACHMENT_ANONYMOUS_TYPE_REFUSED, and the §8.8 file errors |
GET /{id}/attachments/{attachmentId} | Streams a public attachment of the caller's own complaint | 404 COMPLAINT_ATTACHMENT_NOT_FOUND for an internal or foreign attachment |
A complaint the office recorded names its recorder in recorded_by_user_id, and the portal never returns it to that recorder, so an office user cannot confirm, cancel or reopen a recording as if it were their own grievance.
9. Flow Diagrams
9.1 Route Ownership
9.2 Request Sequence: workflow command
9.3 Request Sequence: portal filing
9.4 Error Branch
9.5 Auth and permission flow for allowlisted handlers
9.6 Data contract map
10. Pagination, Sorting, Filtering, and Search
| Endpoint | Pagination Type | Default Size | Max Size | Sort Fields | Filters | Result Cap |
|---|---|---|---|---|---|---|
GET /api/complaints, /assigned-to-me | page/size via PaginationUtil | 20 | 100 | newest, oldest, priority, dueDate, updated, status, category | §6.13 | pagination=false refused |
GET /api/complaints/{id}/events | Keyset on before | 100 | 200 | Newest first across pages, oldest first within a page | Visibility | None |
GET /api/mobile/complaints | page/size | 20 | 100 | newest, oldest, updated | filter, relatedStudentId, q | pagination=false refused |
GET /api/complaint-categories, /api/complaint-templates | page/size | 20 | 100 | Fixed | q, isActive, includeDeleted, audience or categoryId | pagination=false allowed up to UNPAGINATED_HARD_CAP (1000) |
GET /api/complaint-escalation-rules, /api/complaint-sla-rules, /api/complaint-priorities, /api/complaint-notification-settings | None | Fixed | includeDeleted (escalation rules) | Small, closed tables | ||
GET /api/complaint-assignable-users | limit | 20 | 20 | Name | q | 20 |
GET /api/mobile/complaints/student-options, /staff-options | None | Name | q (2-100) | 20 |
The register and portal lists refuse pagination=false because an unpaginated list of grievances is every complainant's account in one response. Search on the register is a case-insensitive ILIKE with the pattern escaped. complaints_title_trgm and complaints_description_trgm are GIN trigram indexes on the two text columns it searches.
11. Caching, Jobs, and External Integrations
| Integration | Used? | Details | Source |
|---|---|---|---|
| Redis cache | No route reads through a cache | The sweep writes REDIS_KEYS.COMPLAINTS_SWEEP_LAST_SUCCESS; statistics reads it for automationLastRunAt | workers/complaint-sweep.service.ts, register/complaint-statistics.service.ts |
| BullMQ | Yes, not triggered by any route | Queue QueueName.COMPLAINTS (complaints), job ComplaintJob.SWEEP (complaints.sweep), enqueued every five minutes by ComplaintScheduler with jobId buildJobId(["complaints-sweep", bucket]) where bucket = Math.round(now / 5 min), attempts: 1, consumed by ComplaintQueueProcessor at concurrency 1. Each pass runs isolated, so one failing pass does not stop the others, and the last-success key is written only for a tick with zero failures | packages/jobs/src/index.ts, workers/complaint.scheduler.ts |
| Notifications | Yes | Every notice goes through ComplaintNotifierService.notify, which writes notification_event and outbox rows in the command's transaction; wording is the reference and fixed text only | shared/complaint-notifier.service.ts |
| Object storage | Yes | Private upload type complaint-attachment; the generic upload proxy refuses the complaint prefix | packages/storage/src/upload-type.ts, modules/upload/upload.service.ts |
| Activity audit | Yes | The global interceptor records every mutation; detail reads and admin downloads add a ComplaintAudit / READ entry. Entries for complaint modules are readable only by a superadmin or a holder of ComplaintAudit_READ, and the endpoint is redacted | activity/recording/activity-audit.interceptor.ts, activity/activity.service.ts |
| External API | No |
13. Mandatory Deep API Documentation Pack
13.1 Route-by-Route Completeness Matrix
Legend. Guards: J+R is JwtAuthGuard then RoleGuard; J+R (A) means the handler is on NO_PERMISSION_ADMIN_HANDLERS. Cache: no route in this module reads or writes a cache. Jobs: Outbox means notification and outbox rows written in the request transaction; no route enqueues a BullMQ job. Errors: B is the base set 404 COMPLAINT_NOT_FOUND, 409 COMPLAINT_ARCHIVED, 409 COMPLAINT_INVALID_TRANSITION, 409 COMPLAINT_VERSION_CONFLICT; every route can also answer 400 VALIDATION_FAILED, 401 and 403. Tests name the file under apps/api/src/modules/complaints/__tests__/ that calls the service method (register, workflow, portal, attachments, config, privacy for complaint-privacy); "None found" means no spec calls it directly.
| Route | Controller Method | DTOs | Service Method | Guards | Permissions | Cache | Jobs | Events | DB Touches | Errors | Tests | Documented? |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
GET /api/complaints | ComplaintsController.findAll | ListComplaintsQueryDto / ComplaintListItemDto[] | ComplaintListService.list(all) | J+R | Complaints_READ | None | None | None | complaints, joins | 400 PAGINATION_LIMIT_INVALID | register, privacy | Yes (§8.1) |
GET /api/complaints/assigned-to-me | ComplaintsController.findAssignedToMe | same | ComplaintListService.list(assigned) | J+R | ComplaintAssignments_READ | None | None | None | complaints, joins | 400 PAGINATION_LIMIT_INVALID | register, privacy | Yes (§8.1) |
POST /api/complaints | ComplaintsController.record | RecordComplaintDto / ComplaintCommandResultDto | ComplaintOfficeRecordService.record | J+R | Complaints_CREATE | None | Outbox | submitted; submitted_complainant, submitted_triage | complaints, complaint_events, sequence | §8.2 | register | Yes (§8.2) |
GET /api/complaints/{id} | ComplaintsController.findOne | ComplaintDetailDto | ComplaintAccessService.resolveVisible, ComplaintReadService.buildDetail | J+R (A) | None declared | None | None | ComplaintAudit activity entry | All complaint tables | 403 AUTH_ACTIVE_ROLE_REQUIRED, 404 COMPLAINT_NOT_FOUND | register, privacy | Yes (§8.3) |
GET /api/complaints/{id}/events | ComplaintsController.listEvents | ListComplaintEventsQueryDto / ComplaintEventPageDto | ComplaintReadService.listEvents | J+R (A) | None declared | None | None | None | complaint_events, complaint_attachments | 403 AUTH_ACTIVE_ROLE_REQUIRED, 404 COMPLAINT_NOT_FOUND, 400 COMPLAINT_EVENT_CURSOR_INVALID | register, privacy | Yes (§8.3) |
POST /api/complaints/{id}/acknowledge | ComplaintActionsController.acknowledge | AcknowledgeComplaintDto / ComplaintCommandResultDto | ComplaintWorkflowService.acknowledge | J+R | ComplaintTriage_UPDATE | None | Outbox | acknowledged; acknowledged_complainant | complaints, complaint_events | B | workflow | Yes (§8.5) |
POST /api/complaints/{id}/assign | ComplaintActionsController.assign | AssignComplaintDto | ComplaintWorkflowService.assign | J+R | ComplaintAssignments_CREATE | None | Outbox | assigned (and acknowledged); assigned_assignee, assigned_complainant | complaints, complaint_assignments, complaint_events | B, 409 COMPLAINT_ALREADY_ASSIGNED, 422 COMPLAINT_ASSIGNEE_INELIGIBLE | workflow | Yes (§8.4) |
POST /api/complaints/{id}/reassign | ComplaintActionsController.reassign | ReassignComplaintDto | ComplaintWorkflowService.reassign | J+R | ComplaintAssignments_UPDATE | None | Outbox | reassigned; assigned_assignee, reassigned_previous_assignee, assigned_complainant | complaints, complaint_assignments, complaint_events | B, 409 COMPLAINT_NOT_ASSIGNED, 409 COMPLAINT_SAME_ASSIGNEE, 422 COMPLAINT_ASSIGNEE_INELIGIBLE | workflow | Yes (§8.5) |
POST /api/complaints/{id}/unassign | ComplaintActionsController.unassign | UnassignComplaintDto | ComplaintWorkflowService.unassign | J+R | ComplaintAssignments_DELETE | None | Outbox | unassigned; reassigned_previous_assignee | complaints, complaint_assignments, complaint_events | B, 409 COMPLAINT_NOT_ASSIGNED | None found | Yes (§8.5) |
POST /api/complaints/{id}/start | ComplaintActionsController.start | ExpectedVersionDto | ComplaintWorkflowService.start | J+R | ComplaintAssignments_READ | None | None | investigation_started | complaints, complaint_events | B, 403 COMPLAINT_NOT_CURRENT_ASSIGNEE | workflow, register, privacy | Yes (§8.5) |
POST /api/complaints/{id}/responses | ComplaintActionsController.addResponse | AddResponseDto | ComplaintWorkflowService.addResponse | J+R | ComplaintResponses_CREATE | None | Outbox | public_response; response_added_complainant | complaints, complaint_events | B | workflow, privacy | Yes (§8.5) |
POST /api/complaints/{id}/notes | ComplaintActionsController.addNote | AddNoteDto | ComplaintWorkflowService.addNote | J+R | ComplaintInternalNotes_CREATE | None | Outbox | internal_note; investigation_update_complainant | complaints, complaint_events, complaint_automation_log | B | workflow, register, privacy | Yes (§8.5) |
POST /api/complaints/{id}/information-requests | ComplaintActionsController.requestInformation | RequestInformationDto | ComplaintWorkflowService.requestInformation | J+R | ComplaintInformationRequests_CREATE | None | Outbox | information_requested; information_requested_complainant | complaints, complaint_info_requests, complaint_events | B, 409 COMPLAINT_NOT_ASSIGNED, 409 COMPLAINT_INFORMATION_REQUEST_OPEN | workflow | Yes (§8.5) |
POST /api/complaints/{id}/information-requests/{requestId}/withdraw | ComplaintActionsController.withdrawInformationRequest | WithdrawInformationRequestDto | ComplaintWorkflowService.withdrawInformationRequest | J+R | ComplaintInformationRequests_DELETE | None | None | information_request_withdrawn | complaints, complaint_info_requests, complaint_events | B, 404 COMPLAINT_INFORMATION_REQUEST_NOT_FOUND | workflow | Yes (§8.5) |
POST /api/complaints/{id}/priority | ComplaintActionsController.changePriority | ChangePriorityDto | ComplaintWorkflowService.changePriority | J+R | ComplaintPriorityChange_UPDATE | None | None | priority_changed | complaints, complaint_events, target tables | B, 409 COMPLAINT_NO_CHANGE | None found | Yes (§8.5) |
POST /api/complaints/{id}/category | ComplaintActionsController.changeCategory | ChangeCategoryDto | ComplaintWorkflowService.changeCategory | J+R | ComplaintCategoryChange_UPDATE | None | None | category_changed | complaints, complaint_categories, complaint_events | B, 404 COMPLAINT_CATEGORY_NOT_FOUND, 400 COMPLAINT_CATEGORY_INACTIVE, 409 COMPLAINT_NO_CHANGE | None found | Yes (§8.5) |
POST /api/complaints/{id}/due-date | ComplaintActionsController.setDueDate | SetDueDateDto | ComplaintWorkflowService.setDueDate | J+R | Complaints_UPDATE | None | None | due_date_changed | complaints, complaint_events | B, 400 COMPLAINT_DUE_DATE_INVALID | None found | Yes (§8.5) |
POST /api/complaints/{id}/resolve | ComplaintOutcomeActionsController.resolve | ResolveComplaintDto | ComplaintWorkflowService.resolve | J+R | ComplaintResolution_UPDATE | None | Outbox | resolved; resolved_complainant | complaints, complaint_resolutions, complaint_info_requests, complaint_events | B | workflow | Yes (§8.5) |
POST /api/complaints/{id}/close | ComplaintOutcomeActionsController.close | CloseComplaintDto | ComplaintWorkflowService.close | J+R | ComplaintClosure_UPDATE | None | Outbox | closed; closed_complainant | complaints, complaint_events | B, 409 COMPLAINT_CLOSURE_REQUIRES_OVERRIDE | workflow | Yes (§8.5) |
POST /api/complaints/{id}/reopen | ComplaintOutcomeActionsController.reopen | ReopenComplaintDto | ComplaintWorkflowService.reopen | J+R | ComplaintReopen_UPDATE | None | Outbox | reopened; reopened_handlers | complaints, complaint_resolutions, complaint_events | B | workflow, portal | Yes (§8.5) |
POST /api/complaints/{id}/reject | ComplaintOutcomeActionsController.reject | RejectComplaintDto | ComplaintWorkflowService.reject | J+R | ComplaintRejection_UPDATE | None | Outbox | rejected; rejected_complainant | complaints, complaint_info_requests, complaint_events | B | workflow | Yes (§8.5) |
POST /api/complaints/{id}/mark-duplicate | ComplaintOutcomeActionsController.markDuplicate | MarkDuplicateComplaintDto | ComplaintWorkflowService.markDuplicate | J+R | ComplaintRejection_UPDATE | None | Outbox | marked_duplicate, internal note on the target; rejected_complainant | complaints (two rows), complaint_events | B, 400 COMPLAINT_DUPLICATE_TARGET_INVALID | workflow | Yes (§8.5) |
POST /api/complaints/{id}/cancel | ComplaintOutcomeActionsController.cancel | CancelComplaintDto | ComplaintWorkflowService.cancel | J+R | ComplaintCancellation_UPDATE | None | Outbox | cancelled; cancelled_complainant | complaints, complaint_info_requests, complaint_events | B | workflow | Yes (§8.5) |
POST /api/complaints/{id}/escalate | ComplaintOutcomeActionsController.escalate | EscalateComplaintDto | ComplaintWorkflowService.escalate | J+R | ComplaintEscalation_UPDATE | None | Outbox | escalated; escalated_recipients | complaints, complaint_events | B, 422 COMPLAINT_ESCALATION_RECIPIENT_INVALID | workflow | Yes (§8.5) |
POST /api/complaints/{id}/archive | ComplaintOutcomeActionsController.archive | ExpectedVersionDto | ComplaintWorkflowService.archive | J+R | Complaints_DELETE | None | None | archived | complaints, complaint_resolutions, complaint_events | B, 409 COMPLAINT_ARCHIVE_TOO_EARLY | workflow | Yes (§8.5) |
POST /api/complaints/{id}/unarchive | ComplaintOutcomeActionsController.unarchive | ExpectedVersionDto | ComplaintWorkflowService.unarchive | J+R | Complaints_RESTORE | None | None | unarchived | complaints, complaint_events | B, 409 COMPLAINT_NOT_ARCHIVED | workflow | Yes (§8.5) |
POST /api/complaints/{id}/identity-reveal | ComplaintOutcomeActionsController.revealIdentity | RevealIdentityDto / ComplaintIdentityDto | ComplaintWorkflowService.revealIdentity | J+R | ComplaintIdentity_READ | None | None | identity_viewed | complaints, users, complaint_attachments, complaint_events | 404 COMPLAINT_NOT_FOUND, 409 COMPLAINT_INVALID_TRANSITION, 409 COMPLAINT_NOT_ANONYMOUS | privacy, workflow | Yes (§8.6) |
POST /api/complaints/{id}/attachments | ComplaintAttachmentsController.create | ComplaintAttachmentUploadDto / ComplaintAttachmentDto | ComplaintAttachmentService.uploadForAdmin | J+R | ComplaintAttachments_CREATE | None | None | attachment_added for a loose file | complaint_attachments, complaint_events, storage | B, §8.8 | attachments | Yes (§8.8) |
GET /api/complaints/{id}/attachments/{attachmentId} | ComplaintAttachmentsController.download | Binary | ComplaintAttachmentDownloadService.downloadForAdmin | J+R (A) | None declared | None | None | ComplaintAudit activity entry | complaint_attachments, storage | 403 AUTH_ACTIVE_ROLE_REQUIRED, 404 COMPLAINT_NOT_FOUND, 404 COMPLAINT_ATTACHMENT_NOT_FOUND | attachments | Yes (§8.8) |
POST /api/complaint-bulk/acknowledge | ComplaintBulkController.acknowledge | BulkAcknowledgeDto / ComplaintBulkResultDto | ComplaintBulkService.acknowledge | J+R | ComplaintTriage_UPDATE | None | Outbox | per item as acknowledge | as acknowledge | 400 COMPLAINT_BULK_LIMIT_EXCEEDED; per item B | workflow | Yes (§8.7) |
POST /api/complaint-bulk/assign | ComplaintBulkController.assign | BulkAssignDto | ComplaintBulkService.assign | J+R | ComplaintAssignments_CREATE | None | Outbox | per item as assign | as assign | as above | None found | Yes (§8.7) |
POST /api/complaint-bulk/priority | ComplaintBulkController.priority | BulkPriorityDto | ComplaintBulkService.priority | J+R | ComplaintPriorityChange_UPDATE | None | None | per item as priority | as priority | as above | None found | Yes (§8.7) |
POST /api/complaint-bulk/category | ComplaintBulkController.category | BulkCategoryDto | ComplaintBulkService.category | J+R | ComplaintCategoryChange_UPDATE | None | None | per item as category | as category | as above | None found | Yes (§8.7) |
POST /api/complaint-bulk/close | ComplaintBulkController.close | BulkCloseDto | ComplaintBulkService.close | J+R | ComplaintClosure_UPDATE | None | Outbox | per item as close | as close | as above | None found | Yes (§8.7) |
POST /api/complaint-bulk/reject | ComplaintBulkController.reject | BulkReasonDto | ComplaintBulkService.reject | J+R | ComplaintRejection_UPDATE | None | Outbox | per item as reject | as reject | as above | None found | Yes (§8.7) |
POST /api/complaint-bulk/cancel | ComplaintBulkController.cancel | BulkReasonDto | ComplaintBulkService.cancel | J+R | ComplaintCancellation_UPDATE | None | Outbox | per item as cancel | as cancel | as above | None found | Yes (§8.7) |
POST /api/complaint-bulk/archive | ComplaintBulkController.archive | BulkArchiveDto | ComplaintBulkService.archive | J+R | Complaints_DELETE | None | None | per item as archive | as archive | as above | None found | Yes (§8.7) |
GET /api/complaint-statistics | ComplaintStatisticsController.get | ComplaintStatisticsQueryDto / ComplaintStatisticsDto | ComplaintStatisticsService.build | J+R | ComplaintStatistics_READ | Reads one Redis key | None | None | complaints, joins | 400 VALIDATION_FAILED | register | Yes (§8.9) |
GET /api/complaint-assignable-users | ComplaintAssignableUsersController.list | ComplaintAssignableUsersQueryDto / ComplaintAssignableUserDto[] | ComplaintPickerService.list | J+R (A) | Any of four (§5) | None | None | None | users, staff, role tables | 403, 403 AUTH_ACTIVE_ROLE_REQUIRED | register | Yes (§8.9) |
GET /api/complaint-categories | ComplaintCategoriesController.list | ListComplaintCategoriesQueryDto | ComplaintCategoriesService.list | J+R | ComplaintCategories_READ | None | None | None | complaint_categories | 400 VALIDATION_FAILED | config | Yes (§8.10) |
GET /api/complaint-categories/{publicId} | ComplaintCategoriesController.findOne | ComplaintCategoryDto | ComplaintCategoriesService.findOne | J+R | ComplaintCategories_READ | None | None | None | complaint_categories | 404 COMPLAINT_CATEGORY_NOT_FOUND | config | Yes (§8.10) |
POST /api/complaint-categories | ComplaintCategoriesController.create | CreateComplaintCategoryDto | ComplaintCategoriesService.create | J+R | ComplaintCategories_CREATE | None | None | Activity entry | complaint_categories | 409 COMPLAINT_CATEGORY_NAME_ALREADY_EXISTS, 400 COMPLAINT_CATEGORY_QUESTIONS_INVALID | config | Yes (§8.10) |
PATCH /api/complaint-categories/{publicId} | ComplaintCategoriesController.update | UpdateComplaintCategoryDto | ComplaintCategoriesService.update | J+R | ComplaintCategories_UPDATE | None | None | Activity entry | complaint_categories | 404, 409 COMPLAINT_CONFIG_VERSION_CONFLICT, name and question errors | config | Yes (§8.10) |
DELETE /api/complaint-categories/{publicId} | ComplaintCategoriesController.remove | ComplaintVersionQueryDto | ComplaintCategoriesService.remove | J+R | ComplaintCategories_DELETE | None | None | Activity entry | complaint_categories, complaint_templates | 404, 409 COMPLAINT_CONFIG_VERSION_CONFLICT, 409 COMPLAINT_CATEGORY_SYSTEM_PROTECTED, 409 COMPLAINT_CATEGORY_HAS_TEMPLATES | config | Yes (§8.10) |
POST /api/complaint-categories/{publicId}/restore | ComplaintCategoriesController.restore | ComplaintVersionBodyDto | ComplaintCategoriesService.restore | J+R | ComplaintCategories_RESTORE | None | None | Activity entry | complaint_categories | 404, 409 COMPLAINT_CONFIG_VERSION_CONFLICT, 409 COMPLAINT_CATEGORY_NAME_ALREADY_EXISTS | config | Yes (§8.10) |
GET /api/complaint-templates | ComplaintTemplatesController.list | ListComplaintTemplatesQueryDto | ComplaintTemplatesService.list | J+R | ComplaintTemplates_READ | None | None | None | complaint_templates | 400 VALIDATION_FAILED | config | Yes (§8.10) |
GET /api/complaint-templates/{publicId} | ComplaintTemplatesController.findOne | ComplaintTemplateDto | ComplaintTemplatesService.findOne | J+R | ComplaintTemplates_READ | None | None | None | complaint_templates | 404 COMPLAINT_TEMPLATE_NOT_FOUND | config | Yes (§8.10) |
POST /api/complaint-templates | ComplaintTemplatesController.create | CreateComplaintTemplateDto | ComplaintTemplatesService.create | J+R | ComplaintTemplates_CREATE | None | None | Activity entry | complaint_templates | 404 COMPLAINT_CATEGORY_NOT_FOUND, 400 COMPLAINT_CATEGORY_INACTIVE, 409 COMPLAINT_TEMPLATE_NAME_ALREADY_EXISTS | config | Yes (§8.10) |
PATCH /api/complaint-templates/{publicId} | ComplaintTemplatesController.update | UpdateComplaintTemplateDto | ComplaintTemplatesService.update | J+R | ComplaintTemplates_UPDATE | None | None | Activity entry | complaint_templates | as create, plus 409 COMPLAINT_CONFIG_VERSION_CONFLICT | config | Yes (§8.10) |
DELETE /api/complaint-templates/{publicId} | ComplaintTemplatesController.remove | ComplaintVersionQueryDto | ComplaintTemplatesService.remove | J+R | ComplaintTemplates_DELETE | None | None | Activity entry | complaint_templates | 404, 409 COMPLAINT_CONFIG_VERSION_CONFLICT | config | Yes (§8.10) |
POST /api/complaint-templates/{publicId}/restore | ComplaintTemplatesController.restore | ComplaintVersionBodyDto | ComplaintTemplatesService.restore | J+R | ComplaintTemplates_RESTORE | None | None | Activity entry | complaint_templates | 404, 409 | config | Yes (§8.10) |
GET /api/complaint-priorities | ComplaintPrioritiesController.list | ComplaintPrioritySettingDto[] | ComplaintPrioritiesService.list | J+R | ComplaintPriorities_READ | None | None | None | complaint_priority_settings | config | Yes (§8.10) | |
PATCH /api/complaint-priorities/{priority} | ComplaintPrioritiesController.update | UpdateComplaintPrioritySettingDto | ComplaintPrioritiesService.update | J+R | ComplaintPriorities_UPDATE | None | None | Activity entry | complaint_priority_settings | 400 VALIDATION_FAILED, 409 COMPLAINT_CONFIG_VERSION_CONFLICT | config | Yes (§8.10) |
GET /api/complaint-sla-rules | ComplaintSlaRulesController.list | ComplaintSlaRuleDto[] | ComplaintSlaRulesService.list | J+R | ComplaintPriorities_READ | None | None | None | complaint_sla_rules | config | Yes (§8.10) | |
POST /api/complaint-sla-rules | ComplaintSlaRulesController.create | CreateComplaintSlaRuleDto | ComplaintSlaRulesService.create | J+R | ComplaintPriorities_CREATE | None | None | Activity entry | complaint_sla_rules | 404 COMPLAINT_CATEGORY_NOT_FOUND, 409 COMPLAINT_SLA_RULE_ALREADY_EXISTS | config | Yes (§8.10) |
PATCH /api/complaint-sla-rules/{publicId} | ComplaintSlaRulesController.update | UpdateComplaintSlaRuleDto | ComplaintSlaRulesService.update | J+R | ComplaintPriorities_UPDATE | None | None | Activity entry | complaint_sla_rules | 404 COMPLAINT_SLA_RULE_NOT_FOUND, 409 COMPLAINT_CONFIG_VERSION_CONFLICT | config | Yes (§8.10) |
DELETE /api/complaint-sla-rules/{publicId} | ComplaintSlaRulesController.remove | ComplaintVersionQueryDto | ComplaintSlaRulesService.remove | J+R | ComplaintPriorities_DELETE | None | None | Activity entry | complaint_sla_rules (hard delete) | 404, 409 | config | Yes (§8.10) |
GET /api/complaint-escalation-rules | ComplaintEscalationRulesController.list | ListComplaintEscalationRulesQueryDto | ComplaintEscalationRulesService.list | J+R | ComplaintSettings_READ | None | None | None | complaint_escalation_rules | config | Yes (§8.10) | |
POST /api/complaint-escalation-rules | ComplaintEscalationRulesController.create | CreateComplaintEscalationRuleDto | ComplaintEscalationRulesService.create | J+R | ComplaintSettings_CREATE | None | None | Activity entry | complaint_escalation_rules | 404 COMPLAINT_CATEGORY_NOT_FOUND, 422 COMPLAINT_ESCALATION_RECIPIENT_INVALID | config | Yes (§8.10) |
PATCH /api/complaint-escalation-rules/{publicId} | ComplaintEscalationRulesController.update | UpdateComplaintEscalationRuleDto | ComplaintEscalationRulesService.update | J+R | ComplaintSettings_UPDATE | None | None | Activity entry | complaint_escalation_rules | as create, plus 404 COMPLAINT_ESCALATION_RULE_NOT_FOUND, 409 | config | Yes (§8.10) |
DELETE /api/complaint-escalation-rules/{publicId} | ComplaintEscalationRulesController.remove | ComplaintVersionQueryDto | ComplaintEscalationRulesService.remove | J+R | ComplaintSettings_DELETE | None | None | Activity entry | complaint_escalation_rules | 404, 409 | config | Yes (§8.10) |
POST /api/complaint-escalation-rules/{publicId}/restore | ComplaintEscalationRulesController.restore | ComplaintVersionBodyDto | ComplaintEscalationRulesService.restore | J+R | ComplaintSettings_RESTORE | None | None | Activity entry | complaint_escalation_rules | 404, 409 | config | Yes (§8.10) |
GET /api/complaint-settings | ComplaintSettingsController.get | ComplaintSettingsDto | ComplaintSettingsService.get | J+R | ComplaintSettings_READ | None | None | None | complaint_settings, complaint_reminder_settings | config | Yes (§8.10) | |
PATCH /api/complaint-settings | ComplaintSettingsController.update | UpdateComplaintSettingsDto | ComplaintSettingsService.update | J+R | ComplaintSettings_UPDATE | None | None | Activity entry | complaint_settings | 409 COMPLAINT_CONFIG_VERSION_CONFLICT | config | Yes (§8.10) |
PATCH /api/complaint-settings/reminders/{kind} | ComplaintSettingsController.updateReminder | UpdateComplaintReminderSettingDto | ComplaintSettingsService.updateReminder | J+R | ComplaintSettings_UPDATE | None | None | Activity entry | complaint_reminder_settings | 400 VALIDATION_FAILED, 409 | config | Yes (§8.10) |
GET /api/complaint-notification-settings | ComplaintNotificationSettingsController.list | ComplaintNotificationSettingDto[] | ComplaintNotificationSettingsService.list | J+R | ComplaintNotificationSettings_READ | None | None | None | complaint_notification_settings | config | Yes (§8.10) | |
PATCH /api/complaint-notification-settings/{eventKey} | ComplaintNotificationSettingsController.update | UpdateComplaintNotificationSettingDto | ComplaintNotificationSettingsService.update | J+R | ComplaintNotificationSettings_UPDATE | None | None | Activity entry | complaint_notification_settings | 400 VALIDATION_FAILED, 409 | config | Yes (§8.10) |
GET /api/mobile/complaints/form-options | ComplaintPortalController.formOptions | ComplaintFormOptionsDto | ComplaintPortalQueryService.formOptions | J+R | MyComplaints_CREATE | None | None | None | Config tables, students, guardians | 403 AUTH_ACTIVE_ROLE_REQUIRED, 403 COMPLAINT_AUDIENCE_UNRESOLVED | None found | Yes (§8.12) |
GET /api/mobile/complaints/student-options | ComplaintPortalController.studentOptions | ComplaintPortalSearchQueryDto | ComplaintPortalQueryService.studentOptions | J+R | MyComplaints_CREATE | None | None | None | students, users | as above | None found | Yes (§8.12) |
GET /api/mobile/complaints/staff-options | ComplaintPortalController.staffOptions | ComplaintPortalSearchQueryDto | ComplaintPortalQueryService.staffOptions | J+R | MyComplaints_CREATE | None | None | None | staff, users, designations | as above | None found | Yes (§8.12) |
GET /api/mobile/complaints | ComplaintPortalController.list | ListMyComplaintsQueryDto / ComplaintPortalListItemDto[] | ComplaintPortalQueryService.list | J+R | MyComplaints_READ | None | None | None | complaints, complaint_info_requests | 400 PAGINATION_LIMIT_INVALID | portal | Yes (§8.12) |
POST /api/mobile/complaints | ComplaintPortalController.create | CreateMyComplaintDto / ComplaintPortalCommandResultDto | ComplaintPortalDraftService.create | J+R | MyComplaints_CREATE | None | Outbox (with submit) | submitted (with submit) | complaints, complaint_events | §8.11 | portal | Yes (§8.11) |
GET /api/mobile/complaints/{id} | ComplaintPortalController.detail | ComplaintPortalDetailDto | ComplaintPortalQueryService.detail | J+R | MyComplaints_READ | None | None | None | All complaint tables | 404 COMPLAINT_NOT_FOUND | portal | Yes (§8.12) |
PATCH /api/mobile/complaints/{id} | ComplaintPortalController.update | UpdateMyComplaintDto | ComplaintPortalDraftService.update | J+R | MyComplaints_CREATE | None | None | None | complaints | 404, 409 COMPLAINT_NOT_DRAFT, 409 COMPLAINT_VERSION_CONFLICT | None found | Yes (§8.12) |
DELETE /api/mobile/complaints/{id} | ComplaintPortalController.remove | expectedVersion query | ComplaintPortalDraftService.remove | J+R | MyComplaints_CREATE | None | None | None | complaints, complaint_attachments (cascade), storage | 404, 409 COMPLAINT_NOT_DRAFT, 409 COMPLAINT_VERSION_CONFLICT | portal | Yes (§8.12) |
POST /api/mobile/complaints/{id}/submit | ComplaintPortalController.submit | ComplaintExpectedVersionDto | ComplaintPortalDraftService.submit | J+R | MyComplaints_CREATE | None | Outbox | submitted; submitted_complainant, submitted_triage | complaints, complaint_events | §8.11, 409 COMPLAINT_NOT_DRAFT, 409 COMPLAINT_SUBMITTER_KIND_CHANGED | portal | Yes (§8.12) |
POST /api/mobile/complaints/{id}/information-requests/{requestId}/respond | ComplaintPortalActionsController.respond | ComplaintPortalRespondDto | ComplaintPortalActionsService.respondToInformationRequest | J+R | MyComplaints_UPDATE | None | Outbox | information_provided; information_provided_assignee | complaints, complaint_info_requests, complaint_events | 404, 404 COMPLAINT_INFORMATION_REQUEST_NOT_FOUND, 409 | portal | Yes (§8.12) |
POST /api/mobile/complaints/{id}/confirm-resolution | ComplaintPortalActionsController.confirmResolution | ComplaintExpectedVersionDto | ComplaintPortalActionsService.confirmResolution | J+R | MyComplaints_UPDATE | None | None | closed | complaints, complaint_events | 404, 409 | portal | Yes (§8.12) |
POST /api/mobile/complaints/{id}/reopen | ComplaintPortalActionsController.reopen | ComplaintPortalReasonDto | ComplaintPortalActionsService.reopen | J+R | MyComplaints_UPDATE | None | Outbox | reopened; reopened_handlers | complaints, complaint_resolutions, complaint_events | 404, 409 COMPLAINT_REOPEN_NOT_ALLOWED, 409 COMPLAINT_REOPEN_WINDOW_EXPIRED, 409 | portal | Yes (§8.12) |
POST /api/mobile/complaints/{id}/cancel | ComplaintPortalActionsController.cancel | ComplaintPortalReasonDto | ComplaintPortalActionsService.cancel | J+R | MyComplaints_UPDATE | None | Outbox | cancelled; cancelled_handlers | complaints, complaint_info_requests, complaint_events | 404, 409 COMPLAINT_CANCEL_NOT_ALLOWED, 409 | portal, workflow | Yes (§8.12) |
POST /api/mobile/complaints/{id}/attachments | ComplaintPortalAttachmentsController.uploadAttachment | ComplaintPortalAttachmentUploadDto / ComplaintAttachmentDto | ComplaintAttachmentService.uploadForPortal | J+R | MyComplaints_UPDATE | None | None | attachment_added for a loose file | complaint_attachments, complaint_events, storage | 404, §8.8 | attachments | Yes (§8.12) |
GET /api/mobile/complaints/{id}/attachments/{attachmentId} | ComplaintPortalAttachmentsController.downloadAttachment | Binary | ComplaintAttachmentDownloadService.downloadForPortal | J+R | MyComplaints_READ | None | None | None | complaint_attachments, storage | 404 COMPLAINT_NOT_FOUND, 404 COMPLAINT_ATTACHMENT_NOT_FOUND | attachments | Yes (§8.12) |
82 rows.
13.2 Request/Response Exhaustiveness
| Example Type | Where |
|---|---|
| Minimal valid request | §8.4 (assign), §8.11 (draft) |
| Full valid request | §8.2 (office record), §8.10 (category), §8.11 (anonymous submit) |
| Authenticated request | Every example carries a bearer token; no route is public |
| Admin request | §8.1, §8.2, §8.4, §8.8, §8.10 |
| Success response | §8.1 (list), §8.4 (command), §8.7 (bulk), §8.11 (portal) |
| Empty-list response | §8.1: data: [], count: 0 |
| Validation error | 400 VALIDATION_FAILED with the field message, for example a title shorter than 3 characters after normalisation |
| Domain error | Every family's error table |
| Auth and permission error | 401 without a token; 403 without the route's code; 403 AUTH_ACTIVE_ROLE_REQUIRED on the allowlisted handlers; 429 COMPLAINT_SUBMISSION_LIMIT_REACHED on filing |
A representative domain error:
{
"message": "This complaint changed since you opened it. Reload to see the latest version.",
"errorCode": "COMPLAINT_VERSION_CONFLICT"
}The exact message wording belongs to the throw site; clients branch on errorCode.
13.3 API Diagram Pack
| Diagram | Location |
|---|---|
| Route ownership graph | §9.1 |
| Sequence per major family | §9.2 (workflow), §9.3 (portal filing) |
| Activity and error decision tree | §9.4 |
| Auth and permission flow | §9.5 |
| Data contract map | §9.6 |
| Cache flow | Not applicable: no route caches |
| Async/job flow | Below |
| Lifecycle state diagram | Features and flows |
13.4 Consumer Integration Notes
| Consumer | Required Knowledge | Failure Handling | Contract Stability |
|---|---|---|---|
| Admin panel | Send expectedVersion from the last read on every command. Render buttons from allowedActions and access, never from status. Render every instant in schoolTimezone | COMPLAINT_VERSION_CONFLICT: reload and let the user re-apply. 404 on a complaint the user just saw: visibility changed (reassigned away, or the caller became its subject); remove it from the view. complaint: null in a command result means the same | Stable |
| Portal screens | Generate submissionId once per compose screen and reuse it on retry. Upload files to a draft before /submit when the category requires attachments. Upload follow-up files with the eventId returned by respond or reopen within 30 minutes | 429 COMPLAINT_SUBMISSION_LIMIT_REACHED: show the daily limit. 403 COMPLAINT_AUDIENCE_UNRESOLVED: the active role has no live profile; offer a role switch. 409 COMPLAINT_REOPEN_WINDOW_EXPIRED: hide reopen past reopenDeadline | Stable |
| Bulk UI | Each item succeeds or fails alone | Show per-item errorCode; refresh versions from results[].version | Stable |
| QA | findOne, listEvents, and ComplaintAssignableUsersController.list need a missing-active-role case (they skip RoleGuard's own check). Anonymity needs a check that no admin response, search, picker, activity entry or notification history reveals the submitter | Compare responses for anonymous and non-anonymous fixtures | Stable |
| Internal (sweep) | Not an HTTP consumer | Idempotency lives in complaint_automation_log, not in job retries | Stable |
13.5 API Tradeoffs and Rationale
| Decision | Chosen Behavior | Alternatives Considered | Why This Tradeoff | Risk | Mitigation |
|---|---|---|---|---|---|
| One route per action | POST /api/complaints/{id}/assign, /resolve and so on | One PATCH with a status field | Each action has its own permission, body and side effects, and a status field would let a client request a transition the state machine forbids | More routes | §13.1 lists them all; the structure test fingerprints them |
| Optimistic concurrency | expectedVersion on every mutation | Last write wins | Two handlers, or a handler and a complainant, must not overwrite each other silently | A stale client is refused | 409 COMPLAINT_VERSION_CONFLICT; reload |
| Allowlisted reads | Detail and events resolve access per complaint instead of one route permission | One route permission | "The whole register or my own assignment" is not one permission | The allowlist skips RoleGuard's active-role check | permissionsOf refuses a missing active role |
404 for invisible | Invisible and missing answer the same | 403 for invisible | An id space of grievances must not become an enumeration oracle | Harder debugging | Visibility rules are documented in §3 and §5 |
| Anonymity as masking | Identity is stored; admin reads mask it; one audited route reveals it | Not storing the identity | The school must be able to notify the complainant and, when authorised, identify them | A raw table read shows the identity | Reveal needs a superadmin-only code and writes identity_viewed |
| No notification detail | Notices carry the reference and fixed wording only | Include the title | A title can name a child or a colleague on a lock screen | Less context in the notice | The link opens the complaint |
| Unpaginated lists refused | pagination=false answers 400 on the register and portal | Allow with a cap | A full grievance list in one response is a bulk export by another name | Clients must page | size up to 100 |
| Bulk as repeated single commands | Each item runs the single command in its own transaction | One set-based UPDATE | A bulk path cannot drift from the single path's rules | Slower for 100 items | Cap of 100 |
13.6 API Change Impact
| Change | Affected Consumers | Backend Impact | Data Impact | Migration Needed? | Compatibility Plan |
|---|---|---|---|---|---|
Adding a ComplaintStatus value | Panel, portal | Lifecycle util, list views, statistics byStatus | ALTER TYPE ... ADD VALUE; the status CHECKs are implications and need no rewrite | Yes | Clients must tolerate an unknown status |
| Renaming an action route | Panel | Controller, structure baseline | None | No | Keep the old path until clients move |
| Adding a response field | Panel, portal | Mapper and DTO | None | No | Additive |
| Removing or renaming a response field | Panel, portal | Mapper and DTO | None | No | Coordinate a release with the panel |
| Changing an error code | Panel, portal | Error registry and throw sites | None | No | Clients branch on codes, so treat as breaking |
| Adding a notification event key | Panel settings screen | CHECK constraint, seeds, templates | New complaint_notification_settings row | Yes | Seed it disabled or enabled by decision |
| Adding a permission module | Panel role screen, activity filter | Catalogue, seeds, permissions:sync | Grants | No schema change | The panel's ACTIVITY_MODULES list needs the new name |
14. Zero-Omission API Checklist
- Every controller route is documented (82 of 82, §4 and §13.1).
- Every runtime URL, including the
/api/mobileprefix, is documented. - Every DTO field, enum, default and validator is documented (§6, §7).
- Every response field and its nullability is documented (§6.1-§6.7).
- Every guard, permission and allowlisted handler is documented (§5).
- Success, validation, auth, permission, not-found, conflict, rate-limit and server-unavailable branches are documented (§8).
- Database writes, notifications, outbox rows, activity entries and the sweep job are documented (§8, §11, §13.1).
- Every family has a minimal request, a full request or a representative request, a success response and its failures.
- Every family has route, sequence, activity and error diagrams (§9, §13.3).
- Tradeoffs and compatibility risks are documented (§13.5, §13.6).
- The page links to the backend and features pages.
15. Integration Checklist
- Every route from the controllers is documented.
- Every DTO field is documented.
- Every enum value is documented.
- Every response envelope is documented.
- Every error code this API returns is documented in its family's table.
- Every guard and permission is documented.
- The queue job, Redis key, notifications and activity entries are documented.
- Every diagram matches the current code.
- The page links to the sibling docs.
See Also
- Backend doc:
/docs/developer/complaints/backend - Features and flows doc:
/docs/developer/complaints/feature