Skoolsewa - School Management System Docs
Developer ResourcesComplaints

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

AreaFiles InspectedWhat Was Verified
Routesapps/api/test/structure/structure.baseline.json (jq -r '.routes.all[]', filtered on complaint)82 routes, the authoritative surface
Register controllersmodules/complaints/register/complaints.controller.ts, complaint-statistics.controller.ts, complaint-assignable-users.controller.tsPaths, @Permissions(), handler names, status codes, audit writes
Workflow controllersmodules/complaints/workflow/complaint-actions.controller.ts, complaint-outcome-actions.controller.ts, complaint-bulk.controller.tsOne route per action, body DTOs, permissions
Attachment controllersmodules/complaints/attachments/complaint-attachments.controller.ts, portal/complaint-portal-attachments.controller.tsMultipart upload, download, allowlist entry
Config controllersmodules/complaints/config/*.controller.ts (7 files)CRUD surfaces and the codes that gate them
Portal controllersmodules/complaints/portal/complaint-portal.controller.ts, complaint-portal-actions.controller.tsComplainant routes and their codes
Request DTOsregister/dto/*.ts, workflow/dto/*.ts, attachments/dto/*.ts, config/dto/*.request.dto.ts, portal/dto/*.tsEvery field, validator, default
Response DTOsshared/dto/complaint-response.dto.ts, portal/dto/complaint-form-options.dto.ts, config/dto/*.response.dto.tsEvery field and its nullability
Servicesshared/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.tsValidation order, side effects, error codes
Errorsapps/api/src/common/types/error-codes.ts (the COMPLAINT_ block) and every throw site in the moduleEach code's HTTP status and condition
Permissionspackages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.ts, apps/api/src/common/authorization/role.guard.tsModules, seeded grants, the four allowlisted handlers (ComplaintsController.findOne, .listEvents, ComplaintAttachmentsController.download, ComplaintAssignableUsersController.list)
Schemapackages/db/src/schema/school/complaints.tsEnums, persisted fields, constraints
Jobspackages/jobs/src/index.ts, modules/complaints/workers/complaint.scheduler.tsThe one queue and job

2. Module Summary

FieldValue
Module nameComplaints
Module slugcomplaints
Primary actorsComplainants (student, guardian, teacher, staff, office administrator), the triage office, the handler (current assignee), a superadmin, the automation sweep
API surfacesAdmin (/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 modelGlobal JwtAuthGuard, then RoleGuard. Every route declares @Permissions() except four allowlisted admin handlers that resolve access per complaint in the service
PersistencePostgreSQL (Drizzle), BullMQ (QueueName.COMPLAINTS), object storage (upload type complaint-attachment), Redis (one sweep marker)
Runtime source of truthThe complaints row and its child tables; ComplaintAccessService for who sees what
Swagger placementAdmin controllers appear in /api/api-docs; portal controllers appear in /api/mobile-docs
Sibling docsBackend, Features and flows

3. Concepts and Terminology

TermMeaningSource FileUsed By
idThe complaint's uuid. A complaint has no separate publicId; its id is the client-facing identifierschema/school/complaints.tsEvery complaint route
publicIdThe client-facing uuid of a category, template, SLA rule or escalation ruleschema/school/complaints.tsConfig routes
referenceCMP- 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 submittedAtcomplaints.reference (assigned by the complaints_assign_reference trigger)List, detail, notifications
versionThe optimistic-concurrency counter. Every command sends back the value it last read as expectedVersioncomplaints.versionEvery mutating complaint route
expectedVersionThe 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.tsCommands, config updates
VisibilityOn 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 thereshared/complaint-access.service.tsEvery admin complaint route
SubjectThe staff member a complaint names in relatedStaffId. They never see it on the admin surfaceshared/complaint-access.service.tsVisibility, assignment eligibility
AnonymityisAnonymous masks the submitter on every admin read. The identity is stored and only leaves through the audited identity-reveal routeshared/complaint-masking.util.tsResponses, reveal
Office recordA complaint the office files through POST /api/complaints, on someone's behalf or as the office itself. recordedByUserId names the recorderregister/complaint-office-record.service.tsRegister, portal
Portal audienceWho 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 fieldportal/complaint-portal-audience.service.tsPortal routes
allowedActionsThe exact actions the caller may take now, computed server-side from status, archive state, permissions and assignmentcomplaint-lifecycle.util.ts, ComplaintAccessService.heldActionsDetail responses
dueSourceWhich configuration supplied the resolution deadline: priority, category, sla_rule, template or manualutils/complaints/complaint-sla.util.tsDetail
SLA pauseWhile waiting_for_information, the clock is paused; the paused time is added to the deadline when it resumescomplaints.sla_paused_at, sla_paused_secondsWorkflow, portal
schoolTimezoneThe 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 zoneComplaintPolicyService.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.

SurfaceMethodPathActorPermissionController
AdminGET/api/complaintsOfficeComplaints_READComplaintsController.findAll
AdminGET/api/complaints/assigned-to-meHandlerComplaintAssignments_READComplaintsController.findAssignedToMe
AdminPOST/api/complaintsOfficeComplaints_CREATEComplaintsController.record
AdminGET/api/complaints/{id}Office, handlerNone declared (allowlisted)ComplaintsController.findOne
AdminGET/api/complaints/{id}/eventsOffice, handlerNone declared (allowlisted)ComplaintsController.listEvents
AdminPOST/api/complaints/{id}/acknowledgeTriageComplaintTriage_UPDATEComplaintActionsController.acknowledge
AdminPOST/api/complaints/{id}/assignTriageComplaintAssignments_CREATEComplaintActionsController.assign
AdminPOST/api/complaints/{id}/reassignTriageComplaintAssignments_UPDATEComplaintActionsController.reassign
AdminPOST/api/complaints/{id}/unassignTriageComplaintAssignments_DELETEComplaintActionsController.unassign
AdminPOST/api/complaints/{id}/startCurrent assigneeComplaintAssignments_READComplaintActionsController.start
AdminPOST/api/complaints/{id}/responsesHandlerComplaintResponses_CREATEComplaintActionsController.addResponse
AdminPOST/api/complaints/{id}/notesHandlerComplaintInternalNotes_CREATEComplaintActionsController.addNote
AdminPOST/api/complaints/{id}/information-requestsHandlerComplaintInformationRequests_CREATEComplaintActionsController.requestInformation
AdminPOST/api/complaints/{id}/information-requests/{requestId}/withdrawHandlerComplaintInformationRequests_DELETEComplaintActionsController.withdrawInformationRequest
AdminPOST/api/complaints/{id}/priorityTriageComplaintPriorityChange_UPDATEComplaintActionsController.changePriority
AdminPOST/api/complaints/{id}/categoryTriageComplaintCategoryChange_UPDATEComplaintActionsController.changeCategory
AdminPOST/api/complaints/{id}/due-dateOfficeComplaints_UPDATEComplaintActionsController.setDueDate
AdminPOST/api/complaints/{id}/resolveHandlerComplaintResolution_UPDATEComplaintOutcomeActionsController.resolve
AdminPOST/api/complaints/{id}/closeHandlerComplaintClosure_UPDATEComplaintOutcomeActionsController.close
AdminPOST/api/complaints/{id}/reopenOfficeComplaintReopen_UPDATEComplaintOutcomeActionsController.reopen
AdminPOST/api/complaints/{id}/rejectTriageComplaintRejection_UPDATEComplaintOutcomeActionsController.reject
AdminPOST/api/complaints/{id}/mark-duplicateTriageComplaintRejection_UPDATEComplaintOutcomeActionsController.markDuplicate
AdminPOST/api/complaints/{id}/cancelOfficeComplaintCancellation_UPDATEComplaintOutcomeActionsController.cancel
AdminPOST/api/complaints/{id}/escalateHandler, officeComplaintEscalation_UPDATEComplaintOutcomeActionsController.escalate
AdminPOST/api/complaints/{id}/archiveOfficeComplaints_DELETEComplaintOutcomeActionsController.archive
AdminPOST/api/complaints/{id}/unarchiveOfficeComplaints_RESTOREComplaintOutcomeActionsController.unarchive
AdminPOST/api/complaints/{id}/identity-revealAuthorised officerComplaintIdentity_READComplaintOutcomeActionsController.revealIdentity
AdminPOST/api/complaints/{id}/attachmentsHandlerComplaintAttachments_CREATEComplaintAttachmentsController.create
AdminGET/api/complaints/{id}/attachments/{attachmentId}Office, handlerNone declared (allowlisted)ComplaintAttachmentsController.download
AdminPOST/api/complaint-bulk/acknowledgeTriageComplaintTriage_UPDATEComplaintBulkController.acknowledge
AdminPOST/api/complaint-bulk/assignTriageComplaintAssignments_CREATEComplaintBulkController.assign
AdminPOST/api/complaint-bulk/priorityTriageComplaintPriorityChange_UPDATEComplaintBulkController.priority
AdminPOST/api/complaint-bulk/categoryTriageComplaintCategoryChange_UPDATEComplaintBulkController.category
AdminPOST/api/complaint-bulk/closeHandlerComplaintClosure_UPDATEComplaintBulkController.close
AdminPOST/api/complaint-bulk/rejectTriageComplaintRejection_UPDATEComplaintBulkController.reject
AdminPOST/api/complaint-bulk/cancelOfficeComplaintCancellation_UPDATEComplaintBulkController.cancel
AdminPOST/api/complaint-bulk/archiveOfficeComplaints_DELETEComplaintBulkController.archive
AdminGET/api/complaint-statisticsOfficeComplaintStatistics_READComplaintStatisticsController.get
AdminGET/api/complaint-assignable-usersTriage, escalation configNone declared (allowlisted)ComplaintAssignableUsersController.list
AdminGET/api/complaint-categoriesOfficeComplaintCategories_READComplaintCategoriesController.list
AdminGET/api/complaint-categories/{publicId}OfficeComplaintCategories_READComplaintCategoriesController.findOne
AdminPOST/api/complaint-categoriesOfficeComplaintCategories_CREATEComplaintCategoriesController.create
AdminPATCH/api/complaint-categories/{publicId}OfficeComplaintCategories_UPDATEComplaintCategoriesController.update
AdminDELETE/api/complaint-categories/{publicId}OfficeComplaintCategories_DELETEComplaintCategoriesController.remove
AdminPOST/api/complaint-categories/{publicId}/restoreOfficeComplaintCategories_RESTOREComplaintCategoriesController.restore
AdminGET/api/complaint-templatesOfficeComplaintTemplates_READComplaintTemplatesController.list
AdminGET/api/complaint-templates/{publicId}OfficeComplaintTemplates_READComplaintTemplatesController.findOne
AdminPOST/api/complaint-templatesOfficeComplaintTemplates_CREATEComplaintTemplatesController.create
AdminPATCH/api/complaint-templates/{publicId}OfficeComplaintTemplates_UPDATEComplaintTemplatesController.update
AdminDELETE/api/complaint-templates/{publicId}OfficeComplaintTemplates_DELETEComplaintTemplatesController.remove
AdminPOST/api/complaint-templates/{publicId}/restoreOfficeComplaintTemplates_RESTOREComplaintTemplatesController.restore
AdminGET/api/complaint-prioritiesOfficeComplaintPriorities_READComplaintPrioritiesController.list
AdminPATCH/api/complaint-priorities/{priority}OfficeComplaintPriorities_UPDATEComplaintPrioritiesController.update
AdminGET/api/complaint-sla-rulesOfficeComplaintPriorities_READComplaintSlaRulesController.list
AdminPOST/api/complaint-sla-rulesOfficeComplaintPriorities_CREATEComplaintSlaRulesController.create
AdminPATCH/api/complaint-sla-rules/{publicId}OfficeComplaintPriorities_UPDATEComplaintSlaRulesController.update
AdminDELETE/api/complaint-sla-rules/{publicId}OfficeComplaintPriorities_DELETEComplaintSlaRulesController.remove
AdminGET/api/complaint-escalation-rulesOfficeComplaintSettings_READComplaintEscalationRulesController.list
AdminPOST/api/complaint-escalation-rulesOfficeComplaintSettings_CREATEComplaintEscalationRulesController.create
AdminPATCH/api/complaint-escalation-rules/{publicId}OfficeComplaintSettings_UPDATEComplaintEscalationRulesController.update
AdminDELETE/api/complaint-escalation-rules/{publicId}OfficeComplaintSettings_DELETEComplaintEscalationRulesController.remove
AdminPOST/api/complaint-escalation-rules/{publicId}/restoreOfficeComplaintSettings_RESTOREComplaintEscalationRulesController.restore
AdminGET/api/complaint-settingsOfficeComplaintSettings_READComplaintSettingsController.get
AdminPATCH/api/complaint-settingsOfficeComplaintSettings_UPDATEComplaintSettingsController.update
AdminPATCH/api/complaint-settings/reminders/{kind}OfficeComplaintSettings_UPDATEComplaintSettingsController.updateReminder
AdminGET/api/complaint-notification-settingsOfficeComplaintNotificationSettings_READComplaintNotificationSettingsController.list
AdminPATCH/api/complaint-notification-settings/{eventKey}OfficeComplaintNotificationSettings_UPDATEComplaintNotificationSettingsController.update
PortalGET/api/mobile/complaints/form-optionsComplainantMyComplaints_CREATEComplaintPortalController.formOptions
PortalGET/api/mobile/complaints/student-optionsComplainantMyComplaints_CREATEComplaintPortalController.studentOptions
PortalGET/api/mobile/complaints/staff-optionsComplainantMyComplaints_CREATEComplaintPortalController.staffOptions
PortalGET/api/mobile/complaintsComplainantMyComplaints_READComplaintPortalController.list
PortalPOST/api/mobile/complaintsComplainantMyComplaints_CREATEComplaintPortalController.create
PortalGET/api/mobile/complaints/{id}ComplainantMyComplaints_READComplaintPortalController.detail
PortalPATCH/api/mobile/complaints/{id}ComplainantMyComplaints_CREATEComplaintPortalController.update
PortalDELETE/api/mobile/complaints/{id}ComplainantMyComplaints_CREATEComplaintPortalController.remove
PortalPOST/api/mobile/complaints/{id}/submitComplainantMyComplaints_CREATEComplaintPortalController.submit
PortalPOST/api/mobile/complaints/{id}/information-requests/{requestId}/respondComplainantMyComplaints_UPDATEComplaintPortalActionsController.respond
PortalPOST/api/mobile/complaints/{id}/confirm-resolutionComplainantMyComplaints_UPDATEComplaintPortalActionsController.confirmResolution
PortalPOST/api/mobile/complaints/{id}/reopenComplainantMyComplaints_UPDATEComplaintPortalActionsController.reopen
PortalPOST/api/mobile/complaints/{id}/cancelComplainantMyComplaints_UPDATEComplaintPortalActionsController.cancel
PortalPOST/api/mobile/complaints/{id}/attachmentsComplainantMyComplaints_UPDATEComplaintPortalAttachmentsController.uploadAttachment
PortalGET/api/mobile/complaints/{id}/attachments/{attachmentId}ComplainantMyComplaints_READComplaintPortalAttachmentsController.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

SurfaceGuard/DecoratorIdentity ShapePermissionGuest AllowedNotes
Admin routes with @Permissions()JwtAuthGuard, RoleGuardreq.user.id, activeRoleThe code in §4, read from the ACTIVE role onlyNoMost complaint-scoped codes also require visibility (§3), so holding a handling code does not open the register
ComplaintsController.findOne, .listEvents, ComplaintAttachmentsController.downloadJwtAuthGuard; RoleGuard passes them through NO_PERMISSION_ADMIN_HANDLERSreq.user.id, activeRoleNone declared. ComplaintAccessService.resolveVisible requires Complaints_READ, or ComplaintAssignments_READ as the current assignee, and excludes the submitter and the subject. A superadmin always passesNoThe 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.listSame allowlistreq.user.id, activeRoleAny of ComplaintAssignments_CREATE, ComplaintAssignments_UPDATE, ComplaintEscalation_UPDATE, ComplaintSettings_UPDATENoReturns only userId, name, designation, and is deliberately not filtered by any complaint's submitter
PortalJwtAuthGuard, RoleGuardreq.user.id, activeRole.scopeKindMyComplaints_READ, MyComplaints_CREATE or MyComplaints_UPDATE per §4NoRows 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).

ModuleGates
ComplaintsThe whole register (_READ), office recording (_CREATE), manual due date (_UPDATE), archive (_DELETE), unarchive (_RESTORE)
MyComplaintsThe complainant's own complaints: read, file and drafts (_CREATE), follow-ups (_UPDATE)
ComplaintAssignmentsThe assigned-to-me queue and starting an investigation (_READ), assign (_CREATE), reassign (_UPDATE), unassign (_DELETE)
ComplaintTriageAcknowledge (_UPDATE); holders with Complaints_READ also receive triage alerts
ComplaintPriorityChange, ComplaintCategoryChangeChanging priority or category (_UPDATE)
ComplaintResponsesPublic responses (_CREATE), and public admin attachments
ComplaintInternalNotesReading notes and internal attachments (_READ), writing notes and internal attachments (_CREATE)
ComplaintInformationRequestsAsking (_CREATE) and withdrawing (_DELETE)
ComplaintAttachmentsThe admin upload route (_CREATE)
ComplaintResolution, ComplaintClosure, ComplaintReopen, ComplaintRejection, ComplaintCancellation, ComplaintEscalationThe matching outcome action (_UPDATE); ComplaintRejection_UPDATE also covers mark-duplicate
ComplaintHistoryInternal, non-note history (_READ)
ComplaintIdentityRevealing an anonymous complainant (_READ); superadmin-only
ComplaintStatisticsThe dashboard (_READ)
ComplaintCategories, ComplaintTemplatesTheir CRUD and restore
ComplaintPrioritiesPriority targets and the category x priority SLA rules
ComplaintSettingsGeneral, closure, attachment and reminder settings, and escalation rules
ComplaintNotificationSettingsPer-event notification switches
ComplaintViewThe panel's complaints hub screen; appears on no API route
ComplaintAuditReading 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

RoleComplaint codes (seed-auth.ts)
superadminEvery 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, studentMyComplaints_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

FieldTypeNullableNotes
iduuidNo
referencestringYesnull only for a draft, which the admin surface never returns
titlestringNo
statusComplaintStatusNo§7
priorityComplaintPriorityNo
category{ publicId, name, isActive, isDeleted }NoStill resolves a deactivated or deleted category
submitter{ userId, displayName, kind, isAnonymous }NoFor an unrevealed anonymous complaint userId, displayName and kind are null
relatedStudent{ id, name, studentCode }Yesid is students.id
gradeName, sectionNamestringYesSnapshotted at submission
academicSession{ publicId, name }Yes
assignee{ userId, name, isActive }YesisActive is false when the account is deleted, banned or cannot log in
submittedAt, ackDueAt, dueAtISO stringYes
isOverduebooleanNoOpen, past due, and not waiting for the complainant
lastActivityAtISO stringNo
resolvedAt, closedAt, archivedAtISO stringYes
hasAttachmentsbooleanNo
escalationLevel, reopenCountintegerNo
versionintegerNoSend back as expectedVersion
schoolTimezonestringNoIANA zone, for example Asia/Kathmandu

6.2 Response: ComplaintDetailDto (extends ComplaintListItemDto)

FieldTypeNullableNotes
descriptionstringNo
answersobjectNoKeyed by question key; values are strings or numbers
questionSnapshotComplaintQuestionDto[]NoThe category's questions as they were at submission
incidentDate, incidentLocationstringYes
contactPreferencestringYesnull for an unrevealed anonymous complaint
relatedStaff{ id, name, employeeCode }Yesid is staff.id
template{ publicId, name }Yes
recordedBy{ userId, name, isActive }YesThe office user who recorded it
acknowledgedAt, firstResponseAtISO stringYes
dueSourceComplaintDueSourceYes
slaPausedbooleanNotrue while waiting for the complainant
rejectionReason, cancellationReasonstringYes
duplicateOf{ id, reference }Yesnull unless the caller can open the target: Complaints_READ, or the caller is the target's assignee
duplicates{ id, reference }[]NoComplaints marked as duplicates of this one, limited to those the caller can open by the same rule
assignmentsComplaintAssignmentDto[]No{ id, assignee, assignedBy, assignedAt, reason, endedAt, endReason, endedBy }; endReason is reassigned or unassigned
infoRequestsComplaintInfoRequestDto[]No{ id, question, requestedBy, requestedAt, responseText, respondedAt, respondedBy, withdrawnAt, withdrawnReason, state }; state is open, answered or withdrawn
resolutionsComplaintResolutionDto[]No{ id, summary, actionTaken, resolvedBy, resolvedAt, followUpDate, followUpNotes, supersededAt, supersededReason, isCurrent }
attachmentsComplaintAttachmentDto[]NoEvery attachment the caller may see
timelineComplaintEventDto[]NoThe newest 500 visible events, oldest first
timelineTruncatedbooleanNotrue when older events exist; page them with GET /events
allowedActionsComplaintAction[]NoExactly what this caller may do now
access{ seesNotes, seesHistory, canRevealIdentity, isAssignee }No

Nested shapes:

DTOFields
ComplaintAttachmentDtoid, 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
ComplaintEventDtoid (complaint_events.public_id), type, visibility, actor, fromValue, toValue, reason, body, attachments, createdAt
ComplaintActorDtouserId (nullable), name (nullable), capacity (complainant, handler, administrator, system)
ComplaintQuestionDtokey, 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

FieldTypeNotes
eventsComplaintEventDto[]One page, oldest first
nextBeforestring or nullPass as before for the next, older page; null when there is none

6.4 Response: ComplaintCommandResultDto and ComplaintPortalCommandResultDto

FieldTypeNotes
complaintComplaintDetailDto (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
eventIdstring or nullPublic 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.

FieldNotes
infoRequests{ id, question, requestedAt, responseText, respondedAt, state } with no internal actor detail
resolutions{ summary, actionTaken, resolvedAt, followUpDate, isCurrent }; followUpNotes is never included
timelineComplaintPortalEventDto[]: { 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
attachmentsPublic attachments only
reopenDeadlineThe last moment the complainant may reopen, when reopening is allowed; otherwise null
allowedActionsA 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

FieldTypeNotes
submitterKindComplaintSubmitterKindWho 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 }[]
childrenComplaintPortalStudentOptionDto[]Guardian only: the caller's own children
selfComplaintPortalStudentOptionDto or nullStudent only: the caller's own placement
canNameStudentsbooleanWhether the caller may name a student at all: self, a child, or via Students_READ
schoolTimezonestringIANA 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

DTOFields
ComplaintStatisticsDtobyStatus (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)
ComplaintBulkResultDtoresults ({ id, ok, errorCode?, message?, version? }[]), succeeded, failed
ComplaintAssignableUserDtouserId, name, designation (nullable)
ComplaintIdentityDtouserId, 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.

FieldTypeRequiredValidation and behaviour
submissionIduuidCreate onlyIdempotency key per submitter. A replay returns the caller's existing complaint with message Already saved.
submitbooleanNo (create only), default falsetrue submits in the same request
expectedVersionintegerUpdate only
titlestringYes3-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
descriptionstringNoAt most 10000; at submission at least 10 characters after trimming
categoryIduuidYesThe category's publicId
templateIduuidNoMust belong to that category
priorityComplaintPriorityNoAccepted 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
isAnonymousbooleanNo, default false
relatedStudentIduuidNostudents.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
relatedStaffIduuidNostaff.id
incidentDateYYYY-MM-DD (strict ISO-8601)NoNot after the school-local today
incidentLocationstringNoAt most 200
contactPreferencestringNoAt most 500
answersobjectNoKeyed 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)

FieldTypeRequiredValidation and behaviour
submissionIduuidYesA replay returns the existing complaint with HTTP 200 instead of 201
titlestringYesDTO allows 300; the filing rules then require 3-200 after normalisation
descriptionstringYesDTO allows 10000; at least 10 after trimming
categoryIduuidYes
templateIduuidNo
priorityComplaintPriorityNoAlways honoured for an office record
relatedStudentId, relatedStaffIduuidNo
incidentDateISO date stringNoNot in the future
incidentLocationstringNoDTO allows 250; filing rules cap at 200
contactPreferencestringNoDTO allows 550; filing rules cap at 500
answersobjectNo
complainant{ kind, profileId }Nokind 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.

DTORouteExtra fields
AcknowledgeComplaintDtoacknowledgenote? (at most 2000)
AssignComplaintDtoassignassigneeUserId (uuid), reason? (at most 1000)
ReassignComplaintDtoreassignassigneeUserId (uuid), reason (3-1000, required)
UnassignComplaintDtounassignreason (3-1000, required)
ExpectedVersionDtostart, archive, unarchiveNone
AddResponseDtoresponsesbody (1-10000)
AddNoteDtonotesbody (1-10000)
RequestInformationDtoinformation-requestsquestion (3-5000)
WithdrawInformationRequestDto.../withdrawreason (3-1000)
ChangePriorityDtoprioritypriority, reason? (at most 1000)
ChangeCategoryDtocategorycategoryId (uuid, the category publicId), reason? (at most 1000)
SetDueDateDtodue-datedueAt (ISO date-time, or null to restore the computed deadline), reason (3-1000, required)
ResolveComplaintDtoresolvesummary (3-5000), actionTaken (3-5000), followUpDate? (ISO date), followUpNotes? (at most 2000; internal)
CloseComplaintDtoclosenote? (at most 2000), overrideReason? (3-2000)
ReopenComplaintDtoreopenreason (3-2000)
RejectComplaintDtorejectreason (3-2000)
MarkDuplicateComplaintDtomark-duplicateduplicateOfId (uuid), reason? (3-2000; defaults to the fixed text Marked as a duplicate of another complaint.)
CancelComplaintDtocancelreason (3-2000)
EscalateComplaintDtoescalatereason (3-2000), recipientUserIds? (at most 20 uuids)
RevealIdentityDtoidentity-revealreason (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.

DTORouteExtra fields
BulkAcknowledgeDtoacknowledgenote? (at most 2000)
BulkAssignDtoassignassigneeUserId, reason? (at most 1000)
BulkPriorityDtoprioritypriority, reason? (at most 1000)
BulkCategoryDtocategorycategoryId, reason? (at most 1000)
BulkCloseDtoclosenote? (at most 2000), overrideReason? (3-2000)
BulkReasonDtoreject, cancelreason (3-2000)
BulkArchiveDtoarchiveNone

6.12 Request: attachment uploads (multipart/form-data)

DTOFieldRequiredNotes
ComplaintAttachmentUploadDto (admin)fileYesBinary
visibilityWhen eventId is absentpublic needs ComplaintResponses_CREATE, internal needs ComplaintInternalNotes_CREATE; missing answers 400 VALIDATION_FAILED, a missing code 403 AUTH_FORBIDDEN
eventIdNoA 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
ComplaintPortalAttachmentUploadDtofileYesBinary
eventIdNoAn 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.

FieldTypeDefaultNotes
page, size, order, paginationfrom QueryDtopage 1, size 20 (max 100)pagination=false answers 400 PAGINATION_LIMIT_INVALID
viewall, unassigned, overdue, urgent, recent, archivedallall 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
statusComplaintStatus[]
categoryIduuid[]Category publicIds
priorityComplaintPriority[]
submitterKindsubmitter kinds plus anonymous
academicSessionIduuidSession publicId
gradeName, sectionNamestringAt most 80 each
relatedStudentId, assigneeUserIduuid
submittedFrom, submittedToISO dateFilter on submittedAt
overdue, reopened, anonymous, hasAttachmentsbooleanAccept true or 1
qstringAt 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
sortnewest, oldest, priority, dueDate, updated, status, categorynewestEvery sort appends id as a tie-breaker
DTOFields
ListComplaintEventsQueryDtobefore? (an event public id), limit (1-200, default 100)
ComplaintStatisticsQueryDtoacademicSessionId? (session publicId)
ComplaintAssignableUsersQueryDtoq? (at most 100), limit (1-20, default 20)
ListMyComplaintsQueryDtoQueryDto 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
ComplaintPortalSearchQueryDtoq (2-100, required)
ListComplaintCategoriesQueryDtoQueryDto fields plus q?, isActive?, includeDeleted? (default false), audience?
ListComplaintTemplatesQueryDtoQueryDto fields plus q?, isActive?, includeDeleted? (default false), categoryId?
ListComplaintEscalationRulesQueryDtoincludeDeleted? 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
ComplaintVersionQueryDtoexpectedVersion (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.

DTOFields
CreateComplaintCategoryDtoname (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)
CreateComplaintTemplateDtoname (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)
CreateComplaintSlaRuleDtocategoryId (uuid), priority, ackTargetMinutes, resolutionTargetMinutes (1-525600 each). The update DTO changes only the two targets
CreateComplaintEscalationRuleDtoname (at most 80), isActive?, trigger, afterMinutes (1-525600), categoryId? and priority? (null means any), notifyAssigneeManager, notifyTriage, recipientUserIds (at most 20, unique)
UpdateComplaintPrioritySettingDtolabel? (at most 40), description? (at most 500), ackTargetMinutes?, resolutionTargetMinutes? (1-525600), alertOnSubmit?
UpdateComplaintSettingsDtoanonymousEnabled?, 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)
UpdateComplaintReminderSettingDtoisEnabled?, afterHours? (0-8760), repeatHours? (1-720), maxReminders? (1-20)
UpdateComplaintNotificationSettingDtoisEnabled?, channels? (unique subset of in_app, push, email, sms; the table requires at least one channel while enabled)
ComplaintVersionBodyDtoexpectedVersion, 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

EnumValueMeaningRuntime EffectSource
ComplaintStatusdraftUnsubmitted; only its author sees itNo reference, no deadline, deletableschema/school/complaints.ts
submittedFiled, awaiting acknowledgementOpen; counts toward overdue
acknowledgedTriage has seen itOpen
assignedHas a current assigneeOpen; requires an assignee
in_progressThe assignee is working on itOpen
waiting_for_informationAn information request is openSLA clock paused; never overdue
resolvedA current resolution existsAwaits closure per closureMode
closedFinishedTerminal; reopenable
reopenedReopened after an outcomeOpen; new SLA cycle
rejectedRejected or marked duplicateTerminal
cancelledWithdrawnTerminal
ComplaintPrioritylow, medium, high, urgentDeclaration order is sort ordersort=priority puts urgent first; alertOnSubmit on a level switches the triage alert to complaint.urgent_submitted
ComplaintSubmitterKindstudent, guardian, teacher, staff, adminWho filedadmin may not be anonymous
ComplaintDueSourcepriority, category, sla_rule, template, manualWhere the deadline came frommanual survives priority and category changes
ComplaintClosureModemanualStaff close a resolved complaint
automaticThe sweep closes resolutions made under this mode after autoCloseAfterHours
complainant_confirmationThe complainant confirmsStaff closing needs overrideReason
ComplaintEscalationTriggerunacknowledged, unassigned, no_response, unresolved, awaiting_informationWhat an escalation rule watchesA rule with only notifyAssigneeManager is refused for unacknowledged and unassigned
ComplaintEventVisibilitypublic, internalWho may read an event or attachment
ComplaintEventTypesubmitted, 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_viewedThe 26 history event typesDrive the timeline
ComplaintActorCapacitycomplainant, handler, administrator, systemThe capacity an event was written inhandler when the actor is the current assignee, otherwise administrator; system for the sweep
ComplaintAudiencestudent, guardian, staffWho a category is offered toTeachers file under staff
Attachment extensionjpg, jpeg, png, webp, pdf, doc, docx, xls, xlsx, txtThe allowable file typesA school may narrow them in settings
Template required fieldincident_date, incident_location, related_student, related_staff, attachmentsWhat a template demands at submissionrelated_student yields to anonymity
Reminder kindunacknowledged, unassigned, pending_information, approaching_due, overdue, pending_closure, reopenedSweep reminder rowsAll seeded disabled
Notification event keysubmitted_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_complainantThe 20 switchable eventsDisabled or channel-less events send nothing
ComplaintActionupdate_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_identityEvery lifecycle actionAppears in allowedActionsutils/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

EvidencePath
Controllerapps/api/src/modules/complaints/register/complaints.controller.ts (findAll)
DTOregister/dto/list-complaints-query.dto.ts, shared/dto/complaint-response.dto.ts
Serviceregister/complaint-list.service.ts, register/complaint-list-query.ts, shared/complaint-access.service.ts (listVisibilityWhere)
Schemapackages/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-me needs ComplaintAssignments_READ and returns only rows where assigned_to_user_id is the caller.
  • Guest support: None.
  • Rate limit: None specific to this module.
  • Idempotency: Read-only.

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <token>
ParamsNo
QueryNoListComplaintsQueryDto (§6.13)
BodyNo

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 StatusError CodeConditionUser-Facing MeaningSource
400VALIDATION_FAILEDA query field fails its validator, or search is sentFix the queryValidationPipe
400PAGINATION_LIMIT_INVALIDpagination=falseThe register is always paginatedcomplaint-list.service.ts
401No or invalid tokenLog inJwtAuthGuard
403The active role lacks Complaints_READNot permittedRoleGuard

Edge Cases

  • A draft never appears. Neither does a complaint the caller filed or one whose relatedStaffId is the caller's own staff row. A superadmin sees every non-draft complaint.
  • q matches the submitter's name only on non-anonymous complaints, so a search cannot find an anonymous complainant by name.
  • view=all excludes archived complaints; view=archived returns only them.
  • submittedFrom and submittedTo filter submittedAt.

Example Requests

GET /api/complaints?view=overdue&priority=high&priority=urgent&sort=dueDate&page=1&size=20 HTTP/1.1
Authorization: Bearer TOKEN
curl "$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, 201 on first write and 200 on a replayed submissionId.
  • Side effects: complaints row (with referenceNo from complaint_reference_seq), a submitted event with capacity administrator and metadata recorded: true, and two notifications through the outbox: submitted_complainant and submitted_triage (kind complaint.urgent_submitted when the priority level has alertOnSubmit, otherwise complaint.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 StatusError CodeCondition
400VALIDATION_FAILEDDTO failure, or the title, description, location or contact preference fails the normalised length rules
400COMPLAINT_CATEGORY_INACTIVE, COMPLAINT_CATEGORY_NOT_APPLICABLEThe category is deactivated, or not offered to the complainant's audience
400COMPLAINT_TEMPLATE_INACTIVE, COMPLAINT_TEMPLATE_CATEGORY_MISMATCHThe template is deactivated or belongs to another category
400COMPLAINT_REQUIRED_FIELD_MISSINGA template's required field is empty
400COMPLAINT_ANSWERS_INVALIDAn answer does not fit the category's questions
400COMPLAINT_ATTACHMENT_REQUIREDThe category or template requires a file; an office record has none at submission
400COMPLAINT_INCIDENT_DATE_IN_FUTUREincidentDate is after the school-local today
404COMPLAINT_CATEGORY_NOT_FOUND, COMPLAINT_TEMPLATE_NOT_FOUNDUnknown publicId
404COMPLAINT_RELATED_STUDENT_NOT_FOUND, COMPLAINT_RELATED_STAFF_NOT_FOUNDNo live row for the id
409COMPLAINT_VERSION_CONFLICTThe insert on submissionId conflicted but no complaint of this submitter carries that id, so there is nothing to replay
422COMPLAINT_COMPLAINANT_INVALIDNo live profile of that kind, or the account is deleted, banned or cannot log in
422COMPLAINT_COMPLAINANT_IS_RECORDERThe 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 (before is 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

EvidencePath
Controllerworkflow/complaint-actions.controller.ts, workflow/complaint-outcome-actions.controller.ts
DTOworkflow/dto/complaint-action.dto.ts
Serviceworkflow/complaint-workflow.service.ts delegating to complaint-triage.commands.ts, complaint-handling.commands.ts, complaint-outcome.commands.ts; all run through ComplaintCommandBase.execute
Schemacomplaints, 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 with 409 COMPLAINT_VERSION_CONFLICT because 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 UPDATE inside resolveVisible with lock: true) for the whole transaction.
  • If the complaint was never acknowledged, writes acknowledgedAt and an acknowledged event first.
  • Inserts a complaint_assignments row, sets assigned_to_user_id and assigned_at, moves the status to assigned, bumps version.
  • Appends an assigned event.
  • Writes notifications assigned_assignee (to the assignee) and assigned_complainant (seeded disabled) through NotificationService.send, which writes notification_event and 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_id matches the one active assignment.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400VALIDATION_FAILEDBody fails its DTOFix the requestValidationPipe
403Missing ComplaintAssignments_CREATENot permittedRoleGuard
404COMPLAINT_NOT_FOUNDMissing, draft or invisibleRefresh the listComplaintAccessService
409COMPLAINT_ARCHIVEDThe complaint is archivedUnarchive firstassertCanPerform
409COMPLAINT_INVALID_TRANSITIONStatus is not submitted, acknowledged or reopenedReload; someone moved itassertCanPerform
409COMPLAINT_VERSION_CONFLICTexpectedVersion is staleReload and retryassertVersion
409COMPLAINT_ALREADY_ASSIGNEDAn active assignment existsUse reassigncomplaint-triage.commands.ts
422COMPLAINT_ASSIGNEE_INELIGIBLEThe 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 submitterPick someone elseComplaintPeopleService.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 reopened complaint can be assigned only after its previous assignment ended; otherwise use reassign, which moves it to assigned.

Example Requests

POST /api/complaints/0192f0c1-6c2e-7a55-9d7e-3f1a2b4c5d6e/assign HTTP/1.1
Authorization: Bearer TOKEN
Content-Type: application/json
curl -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.

RouteFromToWritesHistory eventNotification keyExtra errors
acknowledgesubmittedacknowledgedacknowledgedAtacknowledgedacknowledged_complainant
reassignassigned, in_progress, waiting_for_information, reopenedassigned from reopened, otherwise unchangedEnds the active assignment (reassigned), inserts a new onereassignedassigned_assignee, reassigned_previous_assignee, assigned_complainant409 COMPLAINT_NOT_ASSIGNED, 409 COMPLAINT_SAME_ASSIGNEE, 422 COMPLAINT_ASSIGNEE_INELIGIBLE
unassignassigned, reopenedacknowledged from assigned, otherwise unchangedEnds the active assignment (unassigned), clears the assigneeunassignedreassigned_previous_assignee409 COMPLAINT_NOT_ASSIGNED
startassigned, reopenedin_progressStatusinvestigation_started403 COMPLAINT_NOT_CURRENT_ASSIGNEE when the caller is not the assignee (checked before the transition)
responsesactive statusesin_progress when the caller is the assignee and the status is assigned or reopened, otherwise unchangedSets firstResponseAt if unsetpublic_response (and investigation_started when it moves)response_added_complainant
notesactive statuses, resolved, closedas responsesAt most one update_notice row per complaint, school-local day and SLA cycle in complaint_automation_loginternal_noteinvestigation_update_complainant (seeded disabled), sent only for the first note of the day; the note body is never sent
information-requestsassigned, in_progress, reopenedwaiting_for_informationInserts a request, sets slaPausedAt, firstResponseAt if unsetinformation_requestedinformation_requested_complainant409 COMPLAINT_NOT_ASSIGNED, 409 COMPLAINT_INFORMATION_REQUEST_OPEN
.../{requestId}/withdrawwaiting_for_informationin_progressSets withdrawnAt, withdrawnReason; resumes the SLA clockinformation_request_withdrawn404 COMPLAINT_INFORMATION_REQUEST_NOT_FOUND, 409 COMPLAINT_INVALID_TRANSITION if already answered or withdrawn
priorityactive statusesunchangedRecomputes dueAt unless dueSource is manual; recomputes ackDueAt only while unacknowledgedpriority_changed409 COMPLAINT_NO_CHANGE
categoryactive statusesunchangedAs prioritycategory_changed404 COMPLAINT_CATEGORY_NOT_FOUND, 400 COMPLAINT_CATEGORY_INACTIVE, 409 COMPLAINT_NO_CHANGE
due-dateactive statusesunchangedA date-time sets dueAt with dueSource: manual; null restores the computed deadlinedue_date_changed400 COMPLAINT_DUE_DATE_INVALID (not in the future, or not after submission)
resolveacknowledged, assigned, in_progress, waiting_for_information, reopenedresolvedInserts a resolution carrying the current closureMode; withdraws an open request (reason Resolved) and resumes the clockresolvedresolved_complainant
closeresolvedclosedclosedAtclosed (metadata closureKind: manual or override)closed_complainant409 COMPLAINT_CLOSURE_REQUIRES_OVERRIDE when closureMode is complainant_confirmation and overrideReason is absent
reopenresolved, closed, rejected, cancelledreopenedSupersedes the current resolution; clears the resolved, closed, rejection, duplicate and cancellation fields; increments reopenCount; sets lastReopenedAt; resets the pause; recomputes dueAt from the reopen timereopenedreopened_handlers (the assignee, or triage)
rejectactive statusesrejectedrejectedAt, rejectionReason; withdraws an open request (reason Rejected)rejectedrejected_complainant
mark-duplicateactive statusesrejectedAs reject, plus duplicateOfComplaintId; from waiting_for_information it withdraws the open request and resumes the clock; an internal note on the targetmarked_duplicate (its toValue, the target's reference, is blanked for callers who cannot open the target)rejected_complainant400 COMPLAINT_DUPLICATE_TARGET_INVALID (self, invisible, draft, itself a duplicate, or the source already has duplicates)
cancelactive statusescancelledcancelledAt, cancellationReason; withdraws an open request and clears the pausecancelledcancelled_complainant
escalateactive statusesunchangedIncrements escalationLevel, sets lastEscalatedAtescalated (metadata manual: true)escalated_recipients to the assignee's manager, triage and the named recipients422 COMPLAINT_ESCALATION_RECIPIENT_INVALID
archiveclosed, rejected, cancelledunchangedarchivedAtarchived409 COMPLAINT_ARCHIVE_TOO_EARLY when a closed complaint is still inside the complainant's reopen window and reopening is on
unarchiveclosed, rejected, cancelled (archived)unchangedClears archivedAtunarchived409 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_viewed event is visible only to holders of ComplaintIdentity_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 StatusError CodeCondition
400COMPLAINT_ATTACHMENT_EMPTYZero-byte file
400COMPLAINT_ATTACHMENT_TYPE_NOT_ALLOWEDExtension not allowed, sniff disagrees, or the structural scan finds a disallowed structure
400COMPLAINT_ATTACHMENT_EVENT_INVALIDeventId is not an attachable event of this complaint written by the caller in the last 30 minutes
400COMPLAINT_ATTACHMENT_VISIBILITY_MISMATCHvisibility conflicts with the event's
422COMPLAINT_ATTACHMENT_ANONYMOUS_TYPE_REFUSEDPortal only: a type other than jpg, jpeg, png, webp or txt on an anonymous complaint
400VALIDATION_FAILEDNeither visibility nor eventId
403AUTH_FORBIDDENMissing the response or note code for the chosen visibility
409COMPLAINT_ATTACHMENT_LIMIT_REACHEDPer-message or lifetime limit
413COMPLAINT_ATTACHMENT_TOO_LARGELarger than maxAttachmentSizeMb, or larger than the multer ceiling
422COMPLAINT_ATTACHMENT_REJECTEDThe scan rejected the content
503COMPLAINT_ATTACHMENT_SCAN_UNAVAILABLE, COMPLAINT_ATTACHMENT_STORAGE_UNAVAILABLEScanner 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.

FamilyRoutesDelete behaviourSpecific errors
Categorieslist (paginated; pagination=false allowed up to 1000 rows; ordered by sortOrder, lower-cased name, id), get, create, update, delete, restoreSoft delete (deleted_at). A seeded isSystem category can be deactivated but never deleted. A category with a live template cannot be deleted404 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)
Templateslist (paginated as categories), get, create, update, delete, restoreSoft delete404 COMPLAINT_TEMPLATE_NOT_FOUND, 409 COMPLAINT_TEMPLATE_NAME_ALREADY_EXISTS, 404 COMPLAINT_CATEGORY_NOT_FOUND, 400 COMPLAINT_CATEGORY_INACTIVE
Prioritieslist, update of the four fixed rowsNo create or delete400 VALIDATION_FAILED for an unknown {priority}
SLA ruleslist (unpaginated), create, update, deleteHard delete; the complaint falls back to the category or priority targets404 COMPLAINT_SLA_RULE_NOT_FOUND, 409 COMPLAINT_SLA_RULE_ALREADY_EXISTS (one rule per category and priority), 404 COMPLAINT_CATEGORY_NOT_FOUND
Escalation ruleslist (unpaginated, ordered by name; includeDeleted optional), create, update, delete, restoreSoft delete404 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)
Settingsget, update, update one reminder kindSingleton400 VALIDATION_FAILED for an unknown {kind}
Notification settingslist, update one event keySeeded set400 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

EvidencePath
Controllerportal/complaint-portal.controller.ts (create)
DTOportal/dto/complaint-portal-request.dto.ts
Serviceportal/complaint-portal-draft.service.ts, shared/complaint-draft.service.ts, shared/complaint-submission.service.ts, portal/complaint-portal-audience.service.ts
Schemacomplaints, 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 dailySubmissionLimit submissions per submitter per school-local day. Both are counted under a per-submitter lock, after the replay check: a replayed submissionId returns 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, computes ackDueAt, dueAt and dueSource, appends a submitted event, and sends submitted_complainant and submitted_triage through the outbox.
  • A student filing non-anonymously names themselves automatically; an anonymous student complaint names nobody.

Error Cases

HTTP StatusError CodeConditionClient Action
400VALIDATION_FAILEDDTO or normalised length failureFix input
400COMPLAINT_ANONYMOUS_NOT_ALLOWEDAnonymity is off for the school or the category, or the filer is adminTurn anonymity off
400COMPLAINT_ANONYMOUS_STUDENT_NOT_ALLOWEDAnonymous and naming a studentDrop one of the two
422COMPLAINT_ATTACHMENT_ANONYMOUS_TYPE_REFUSEDSubmitting an anonymous complaint that carries a complainant-uploaded file other than jpg, jpeg, png, webp or txtRemove the file, or file with your name
400COMPLAINT_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_FUTUREFiling rules at submission (as §8.2)Fix the filing
403AUTH_ACTIVE_ROLE_REQUIREDNo active roleChoose a role
403COMPLAINT_AUDIENCE_UNRESOLVEDA student or guardian role with no live profile rowContact the school
404COMPLAINT_RELATED_STUDENT_NOT_FOUNDNaming a student the caller may not namePick an allowed student
404COMPLAINT_CATEGORY_NOT_FOUND, COMPLAINT_TEMPLATE_NOT_FOUND, COMPLAINT_RELATED_STAFF_NOT_FOUNDUnknown idRefresh form options
400COMPLAINT_PRIORITY_NOT_SELECTABLEpriority sent while settings.complainantCanSetPriority is off (not refused for a caller filing as admin)Leave priority out
409COMPLAINT_DRAFT_LIMIT_REACHED20 open draftsSubmit or discard one
409COMPLAINT_VERSION_CONFLICTThe insert on submissionId conflicted and no complaint of this caller carries itRetry with a new id
429COMPLAINT_SUBMISSION_LIMIT_REACHEDDaily submission limit reachedTry tomorrow

Edge Cases

  • A replay with submit: true does 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: true on 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

RouteBehaviourErrors beyond the identity check (404 COMPLAINT_NOT_FOUND)
GET /form-optionsComplaintFormOptionsDto for the caller's audience; an office administrator (no staff row) sees every live, active category403 AUTH_ACTIVE_ROLE_REQUIRED, 403 COMPLAINT_AUDIENCE_UNRESOLVED
GET /student-optionsUp to 20 live students by name or student code; empty unless the active role holds Students_READSame as above
GET /staff-optionsUp to 20 live staff by name, for every audienceSame 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 attached409 COMPLAINT_NOT_DRAFT, 409 COMPLAINT_VERSION_CONFLICT, 422 COMPLAINT_ATTACHMENT_ANONYMOUS_TYPE_REFUSED, 400 COMPLAINT_PRIORITY_NOT_SELECTABLE, filing-field errors
DELETE /{id}?expectedVersion=nDiscards a draft and its files; stored files are deleted after commit, and a storage failure is logged only409 COMPLAINT_NOT_DRAFT, 409 COMPLAINT_VERSION_CONFLICT
POST /{id}/submitSubmits 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 under409 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}/respondAnswers 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-resolutionresolved to closed; event closed with closureKind: complainant409 COMPLAINT_INVALID_TRANSITION, 409 COMPLAINT_VERSION_CONFLICT
POST /{id}/reopenresolved 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}/cancelActive status to cancelled; notifies cancelled_handlers; body { reason, expectedVersion }409 COMPLAINT_CANCEL_NOT_ALLOWED (setting off), 409 COMPLAINT_INVALID_TRANSITION
POST /{id}/attachmentsAs §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 none400 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 complaint404 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

EndpointPagination TypeDefault SizeMax SizeSort FieldsFiltersResult Cap
GET /api/complaints, /assigned-to-mepage/size via PaginationUtil20100newest, oldest, priority, dueDate, updated, status, category§6.13pagination=false refused
GET /api/complaints/{id}/eventsKeyset on before100200Newest first across pages, oldest first within a pageVisibilityNone
GET /api/mobile/complaintspage/size20100newest, oldest, updatedfilter, relatedStudentId, qpagination=false refused
GET /api/complaint-categories, /api/complaint-templatespage/size20100Fixedq, isActive, includeDeleted, audience or categoryIdpagination=false allowed up to UNPAGINATED_HARD_CAP (1000)
GET /api/complaint-escalation-rules, /api/complaint-sla-rules, /api/complaint-priorities, /api/complaint-notification-settingsNoneFixedincludeDeleted (escalation rules)Small, closed tables
GET /api/complaint-assignable-userslimit2020Nameq20
GET /api/mobile/complaints/student-options, /staff-optionsNoneNameq (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

IntegrationUsed?DetailsSource
Redis cacheNo route reads through a cacheThe sweep writes REDIS_KEYS.COMPLAINTS_SWEEP_LAST_SUCCESS; statistics reads it for automationLastRunAtworkers/complaint-sweep.service.ts, register/complaint-statistics.service.ts
BullMQYes, not triggered by any routeQueue 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 failurespackages/jobs/src/index.ts, workers/complaint.scheduler.ts
NotificationsYesEvery notice goes through ComplaintNotifierService.notify, which writes notification_event and outbox rows in the command's transaction; wording is the reference and fixed text onlyshared/complaint-notifier.service.ts
Object storageYesPrivate upload type complaint-attachment; the generic upload proxy refuses the complaint prefixpackages/storage/src/upload-type.ts, modules/upload/upload.service.ts
Activity auditYesThe 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 redactedactivity/recording/activity-audit.interceptor.ts, activity/activity.service.ts
External APINo

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.

RouteController MethodDTOsService MethodGuardsPermissionsCacheJobsEventsDB TouchesErrorsTestsDocumented?
GET /api/complaintsComplaintsController.findAllListComplaintsQueryDto / ComplaintListItemDto[]ComplaintListService.list(all)J+RComplaints_READNoneNoneNonecomplaints, joins400 PAGINATION_LIMIT_INVALIDregister, privacyYes (§8.1)
GET /api/complaints/assigned-to-meComplaintsController.findAssignedToMesameComplaintListService.list(assigned)J+RComplaintAssignments_READNoneNoneNonecomplaints, joins400 PAGINATION_LIMIT_INVALIDregister, privacyYes (§8.1)
POST /api/complaintsComplaintsController.recordRecordComplaintDto / ComplaintCommandResultDtoComplaintOfficeRecordService.recordJ+RComplaints_CREATENoneOutboxsubmitted; submitted_complainant, submitted_triagecomplaints, complaint_events, sequence§8.2registerYes (§8.2)
GET /api/complaints/{id}ComplaintsController.findOneComplaintDetailDtoComplaintAccessService.resolveVisible, ComplaintReadService.buildDetailJ+R (A)None declaredNoneNoneComplaintAudit activity entryAll complaint tables403 AUTH_ACTIVE_ROLE_REQUIRED, 404 COMPLAINT_NOT_FOUNDregister, privacyYes (§8.3)
GET /api/complaints/{id}/eventsComplaintsController.listEventsListComplaintEventsQueryDto / ComplaintEventPageDtoComplaintReadService.listEventsJ+R (A)None declaredNoneNoneNonecomplaint_events, complaint_attachments403 AUTH_ACTIVE_ROLE_REQUIRED, 404 COMPLAINT_NOT_FOUND, 400 COMPLAINT_EVENT_CURSOR_INVALIDregister, privacyYes (§8.3)
POST /api/complaints/{id}/acknowledgeComplaintActionsController.acknowledgeAcknowledgeComplaintDto / ComplaintCommandResultDtoComplaintWorkflowService.acknowledgeJ+RComplaintTriage_UPDATENoneOutboxacknowledged; acknowledged_complainantcomplaints, complaint_eventsBworkflowYes (§8.5)
POST /api/complaints/{id}/assignComplaintActionsController.assignAssignComplaintDtoComplaintWorkflowService.assignJ+RComplaintAssignments_CREATENoneOutboxassigned (and acknowledged); assigned_assignee, assigned_complainantcomplaints, complaint_assignments, complaint_eventsB, 409 COMPLAINT_ALREADY_ASSIGNED, 422 COMPLAINT_ASSIGNEE_INELIGIBLEworkflowYes (§8.4)
POST /api/complaints/{id}/reassignComplaintActionsController.reassignReassignComplaintDtoComplaintWorkflowService.reassignJ+RComplaintAssignments_UPDATENoneOutboxreassigned; assigned_assignee, reassigned_previous_assignee, assigned_complainantcomplaints, complaint_assignments, complaint_eventsB, 409 COMPLAINT_NOT_ASSIGNED, 409 COMPLAINT_SAME_ASSIGNEE, 422 COMPLAINT_ASSIGNEE_INELIGIBLEworkflowYes (§8.5)
POST /api/complaints/{id}/unassignComplaintActionsController.unassignUnassignComplaintDtoComplaintWorkflowService.unassignJ+RComplaintAssignments_DELETENoneOutboxunassigned; reassigned_previous_assigneecomplaints, complaint_assignments, complaint_eventsB, 409 COMPLAINT_NOT_ASSIGNEDNone foundYes (§8.5)
POST /api/complaints/{id}/startComplaintActionsController.startExpectedVersionDtoComplaintWorkflowService.startJ+RComplaintAssignments_READNoneNoneinvestigation_startedcomplaints, complaint_eventsB, 403 COMPLAINT_NOT_CURRENT_ASSIGNEEworkflow, register, privacyYes (§8.5)
POST /api/complaints/{id}/responsesComplaintActionsController.addResponseAddResponseDtoComplaintWorkflowService.addResponseJ+RComplaintResponses_CREATENoneOutboxpublic_response; response_added_complainantcomplaints, complaint_eventsBworkflow, privacyYes (§8.5)
POST /api/complaints/{id}/notesComplaintActionsController.addNoteAddNoteDtoComplaintWorkflowService.addNoteJ+RComplaintInternalNotes_CREATENoneOutboxinternal_note; investigation_update_complainantcomplaints, complaint_events, complaint_automation_logBworkflow, register, privacyYes (§8.5)
POST /api/complaints/{id}/information-requestsComplaintActionsController.requestInformationRequestInformationDtoComplaintWorkflowService.requestInformationJ+RComplaintInformationRequests_CREATENoneOutboxinformation_requested; information_requested_complainantcomplaints, complaint_info_requests, complaint_eventsB, 409 COMPLAINT_NOT_ASSIGNED, 409 COMPLAINT_INFORMATION_REQUEST_OPENworkflowYes (§8.5)
POST /api/complaints/{id}/information-requests/{requestId}/withdrawComplaintActionsController.withdrawInformationRequestWithdrawInformationRequestDtoComplaintWorkflowService.withdrawInformationRequestJ+RComplaintInformationRequests_DELETENoneNoneinformation_request_withdrawncomplaints, complaint_info_requests, complaint_eventsB, 404 COMPLAINT_INFORMATION_REQUEST_NOT_FOUNDworkflowYes (§8.5)
POST /api/complaints/{id}/priorityComplaintActionsController.changePriorityChangePriorityDtoComplaintWorkflowService.changePriorityJ+RComplaintPriorityChange_UPDATENoneNonepriority_changedcomplaints, complaint_events, target tablesB, 409 COMPLAINT_NO_CHANGENone foundYes (§8.5)
POST /api/complaints/{id}/categoryComplaintActionsController.changeCategoryChangeCategoryDtoComplaintWorkflowService.changeCategoryJ+RComplaintCategoryChange_UPDATENoneNonecategory_changedcomplaints, complaint_categories, complaint_eventsB, 404 COMPLAINT_CATEGORY_NOT_FOUND, 400 COMPLAINT_CATEGORY_INACTIVE, 409 COMPLAINT_NO_CHANGENone foundYes (§8.5)
POST /api/complaints/{id}/due-dateComplaintActionsController.setDueDateSetDueDateDtoComplaintWorkflowService.setDueDateJ+RComplaints_UPDATENoneNonedue_date_changedcomplaints, complaint_eventsB, 400 COMPLAINT_DUE_DATE_INVALIDNone foundYes (§8.5)
POST /api/complaints/{id}/resolveComplaintOutcomeActionsController.resolveResolveComplaintDtoComplaintWorkflowService.resolveJ+RComplaintResolution_UPDATENoneOutboxresolved; resolved_complainantcomplaints, complaint_resolutions, complaint_info_requests, complaint_eventsBworkflowYes (§8.5)
POST /api/complaints/{id}/closeComplaintOutcomeActionsController.closeCloseComplaintDtoComplaintWorkflowService.closeJ+RComplaintClosure_UPDATENoneOutboxclosed; closed_complainantcomplaints, complaint_eventsB, 409 COMPLAINT_CLOSURE_REQUIRES_OVERRIDEworkflowYes (§8.5)
POST /api/complaints/{id}/reopenComplaintOutcomeActionsController.reopenReopenComplaintDtoComplaintWorkflowService.reopenJ+RComplaintReopen_UPDATENoneOutboxreopened; reopened_handlerscomplaints, complaint_resolutions, complaint_eventsBworkflow, portalYes (§8.5)
POST /api/complaints/{id}/rejectComplaintOutcomeActionsController.rejectRejectComplaintDtoComplaintWorkflowService.rejectJ+RComplaintRejection_UPDATENoneOutboxrejected; rejected_complainantcomplaints, complaint_info_requests, complaint_eventsBworkflowYes (§8.5)
POST /api/complaints/{id}/mark-duplicateComplaintOutcomeActionsController.markDuplicateMarkDuplicateComplaintDtoComplaintWorkflowService.markDuplicateJ+RComplaintRejection_UPDATENoneOutboxmarked_duplicate, internal note on the target; rejected_complainantcomplaints (two rows), complaint_eventsB, 400 COMPLAINT_DUPLICATE_TARGET_INVALIDworkflowYes (§8.5)
POST /api/complaints/{id}/cancelComplaintOutcomeActionsController.cancelCancelComplaintDtoComplaintWorkflowService.cancelJ+RComplaintCancellation_UPDATENoneOutboxcancelled; cancelled_complainantcomplaints, complaint_info_requests, complaint_eventsBworkflowYes (§8.5)
POST /api/complaints/{id}/escalateComplaintOutcomeActionsController.escalateEscalateComplaintDtoComplaintWorkflowService.escalateJ+RComplaintEscalation_UPDATENoneOutboxescalated; escalated_recipientscomplaints, complaint_eventsB, 422 COMPLAINT_ESCALATION_RECIPIENT_INVALIDworkflowYes (§8.5)
POST /api/complaints/{id}/archiveComplaintOutcomeActionsController.archiveExpectedVersionDtoComplaintWorkflowService.archiveJ+RComplaints_DELETENoneNonearchivedcomplaints, complaint_resolutions, complaint_eventsB, 409 COMPLAINT_ARCHIVE_TOO_EARLYworkflowYes (§8.5)
POST /api/complaints/{id}/unarchiveComplaintOutcomeActionsController.unarchiveExpectedVersionDtoComplaintWorkflowService.unarchiveJ+RComplaints_RESTORENoneNoneunarchivedcomplaints, complaint_eventsB, 409 COMPLAINT_NOT_ARCHIVEDworkflowYes (§8.5)
POST /api/complaints/{id}/identity-revealComplaintOutcomeActionsController.revealIdentityRevealIdentityDto / ComplaintIdentityDtoComplaintWorkflowService.revealIdentityJ+RComplaintIdentity_READNoneNoneidentity_viewedcomplaints, users, complaint_attachments, complaint_events404 COMPLAINT_NOT_FOUND, 409 COMPLAINT_INVALID_TRANSITION, 409 COMPLAINT_NOT_ANONYMOUSprivacy, workflowYes (§8.6)
POST /api/complaints/{id}/attachmentsComplaintAttachmentsController.createComplaintAttachmentUploadDto / ComplaintAttachmentDtoComplaintAttachmentService.uploadForAdminJ+RComplaintAttachments_CREATENoneNoneattachment_added for a loose filecomplaint_attachments, complaint_events, storageB, §8.8attachmentsYes (§8.8)
GET /api/complaints/{id}/attachments/{attachmentId}ComplaintAttachmentsController.downloadBinaryComplaintAttachmentDownloadService.downloadForAdminJ+R (A)None declaredNoneNoneComplaintAudit activity entrycomplaint_attachments, storage403 AUTH_ACTIVE_ROLE_REQUIRED, 404 COMPLAINT_NOT_FOUND, 404 COMPLAINT_ATTACHMENT_NOT_FOUNDattachmentsYes (§8.8)
POST /api/complaint-bulk/acknowledgeComplaintBulkController.acknowledgeBulkAcknowledgeDto / ComplaintBulkResultDtoComplaintBulkService.acknowledgeJ+RComplaintTriage_UPDATENoneOutboxper item as acknowledgeas acknowledge400 COMPLAINT_BULK_LIMIT_EXCEEDED; per item BworkflowYes (§8.7)
POST /api/complaint-bulk/assignComplaintBulkController.assignBulkAssignDtoComplaintBulkService.assignJ+RComplaintAssignments_CREATENoneOutboxper item as assignas assignas aboveNone foundYes (§8.7)
POST /api/complaint-bulk/priorityComplaintBulkController.priorityBulkPriorityDtoComplaintBulkService.priorityJ+RComplaintPriorityChange_UPDATENoneNoneper item as priorityas priorityas aboveNone foundYes (§8.7)
POST /api/complaint-bulk/categoryComplaintBulkController.categoryBulkCategoryDtoComplaintBulkService.categoryJ+RComplaintCategoryChange_UPDATENoneNoneper item as categoryas categoryas aboveNone foundYes (§8.7)
POST /api/complaint-bulk/closeComplaintBulkController.closeBulkCloseDtoComplaintBulkService.closeJ+RComplaintClosure_UPDATENoneOutboxper item as closeas closeas aboveNone foundYes (§8.7)
POST /api/complaint-bulk/rejectComplaintBulkController.rejectBulkReasonDtoComplaintBulkService.rejectJ+RComplaintRejection_UPDATENoneOutboxper item as rejectas rejectas aboveNone foundYes (§8.7)
POST /api/complaint-bulk/cancelComplaintBulkController.cancelBulkReasonDtoComplaintBulkService.cancelJ+RComplaintCancellation_UPDATENoneOutboxper item as cancelas cancelas aboveNone foundYes (§8.7)
POST /api/complaint-bulk/archiveComplaintBulkController.archiveBulkArchiveDtoComplaintBulkService.archiveJ+RComplaints_DELETENoneNoneper item as archiveas archiveas aboveNone foundYes (§8.7)
GET /api/complaint-statisticsComplaintStatisticsController.getComplaintStatisticsQueryDto / ComplaintStatisticsDtoComplaintStatisticsService.buildJ+RComplaintStatistics_READReads one Redis keyNoneNonecomplaints, joins400 VALIDATION_FAILEDregisterYes (§8.9)
GET /api/complaint-assignable-usersComplaintAssignableUsersController.listComplaintAssignableUsersQueryDto / ComplaintAssignableUserDto[]ComplaintPickerService.listJ+R (A)Any of four (§5)NoneNoneNoneusers, staff, role tables403, 403 AUTH_ACTIVE_ROLE_REQUIREDregisterYes (§8.9)
GET /api/complaint-categoriesComplaintCategoriesController.listListComplaintCategoriesQueryDtoComplaintCategoriesService.listJ+RComplaintCategories_READNoneNoneNonecomplaint_categories400 VALIDATION_FAILEDconfigYes (§8.10)
GET /api/complaint-categories/{publicId}ComplaintCategoriesController.findOneComplaintCategoryDtoComplaintCategoriesService.findOneJ+RComplaintCategories_READNoneNoneNonecomplaint_categories404 COMPLAINT_CATEGORY_NOT_FOUNDconfigYes (§8.10)
POST /api/complaint-categoriesComplaintCategoriesController.createCreateComplaintCategoryDtoComplaintCategoriesService.createJ+RComplaintCategories_CREATENoneNoneActivity entrycomplaint_categories409 COMPLAINT_CATEGORY_NAME_ALREADY_EXISTS, 400 COMPLAINT_CATEGORY_QUESTIONS_INVALIDconfigYes (§8.10)
PATCH /api/complaint-categories/{publicId}ComplaintCategoriesController.updateUpdateComplaintCategoryDtoComplaintCategoriesService.updateJ+RComplaintCategories_UPDATENoneNoneActivity entrycomplaint_categories404, 409 COMPLAINT_CONFIG_VERSION_CONFLICT, name and question errorsconfigYes (§8.10)
DELETE /api/complaint-categories/{publicId}ComplaintCategoriesController.removeComplaintVersionQueryDtoComplaintCategoriesService.removeJ+RComplaintCategories_DELETENoneNoneActivity entrycomplaint_categories, complaint_templates404, 409 COMPLAINT_CONFIG_VERSION_CONFLICT, 409 COMPLAINT_CATEGORY_SYSTEM_PROTECTED, 409 COMPLAINT_CATEGORY_HAS_TEMPLATESconfigYes (§8.10)
POST /api/complaint-categories/{publicId}/restoreComplaintCategoriesController.restoreComplaintVersionBodyDtoComplaintCategoriesService.restoreJ+RComplaintCategories_RESTORENoneNoneActivity entrycomplaint_categories404, 409 COMPLAINT_CONFIG_VERSION_CONFLICT, 409 COMPLAINT_CATEGORY_NAME_ALREADY_EXISTSconfigYes (§8.10)
GET /api/complaint-templatesComplaintTemplatesController.listListComplaintTemplatesQueryDtoComplaintTemplatesService.listJ+RComplaintTemplates_READNoneNoneNonecomplaint_templates400 VALIDATION_FAILEDconfigYes (§8.10)
GET /api/complaint-templates/{publicId}ComplaintTemplatesController.findOneComplaintTemplateDtoComplaintTemplatesService.findOneJ+RComplaintTemplates_READNoneNoneNonecomplaint_templates404 COMPLAINT_TEMPLATE_NOT_FOUNDconfigYes (§8.10)
POST /api/complaint-templatesComplaintTemplatesController.createCreateComplaintTemplateDtoComplaintTemplatesService.createJ+RComplaintTemplates_CREATENoneNoneActivity entrycomplaint_templates404 COMPLAINT_CATEGORY_NOT_FOUND, 400 COMPLAINT_CATEGORY_INACTIVE, 409 COMPLAINT_TEMPLATE_NAME_ALREADY_EXISTSconfigYes (§8.10)
PATCH /api/complaint-templates/{publicId}ComplaintTemplatesController.updateUpdateComplaintTemplateDtoComplaintTemplatesService.updateJ+RComplaintTemplates_UPDATENoneNoneActivity entrycomplaint_templatesas create, plus 409 COMPLAINT_CONFIG_VERSION_CONFLICTconfigYes (§8.10)
DELETE /api/complaint-templates/{publicId}ComplaintTemplatesController.removeComplaintVersionQueryDtoComplaintTemplatesService.removeJ+RComplaintTemplates_DELETENoneNoneActivity entrycomplaint_templates404, 409 COMPLAINT_CONFIG_VERSION_CONFLICTconfigYes (§8.10)
POST /api/complaint-templates/{publicId}/restoreComplaintTemplatesController.restoreComplaintVersionBodyDtoComplaintTemplatesService.restoreJ+RComplaintTemplates_RESTORENoneNoneActivity entrycomplaint_templates404, 409configYes (§8.10)
GET /api/complaint-prioritiesComplaintPrioritiesController.listComplaintPrioritySettingDto[]ComplaintPrioritiesService.listJ+RComplaintPriorities_READNoneNoneNonecomplaint_priority_settingsconfigYes (§8.10)
PATCH /api/complaint-priorities/{priority}ComplaintPrioritiesController.updateUpdateComplaintPrioritySettingDtoComplaintPrioritiesService.updateJ+RComplaintPriorities_UPDATENoneNoneActivity entrycomplaint_priority_settings400 VALIDATION_FAILED, 409 COMPLAINT_CONFIG_VERSION_CONFLICTconfigYes (§8.10)
GET /api/complaint-sla-rulesComplaintSlaRulesController.listComplaintSlaRuleDto[]ComplaintSlaRulesService.listJ+RComplaintPriorities_READNoneNoneNonecomplaint_sla_rulesconfigYes (§8.10)
POST /api/complaint-sla-rulesComplaintSlaRulesController.createCreateComplaintSlaRuleDtoComplaintSlaRulesService.createJ+RComplaintPriorities_CREATENoneNoneActivity entrycomplaint_sla_rules404 COMPLAINT_CATEGORY_NOT_FOUND, 409 COMPLAINT_SLA_RULE_ALREADY_EXISTSconfigYes (§8.10)
PATCH /api/complaint-sla-rules/{publicId}ComplaintSlaRulesController.updateUpdateComplaintSlaRuleDtoComplaintSlaRulesService.updateJ+RComplaintPriorities_UPDATENoneNoneActivity entrycomplaint_sla_rules404 COMPLAINT_SLA_RULE_NOT_FOUND, 409 COMPLAINT_CONFIG_VERSION_CONFLICTconfigYes (§8.10)
DELETE /api/complaint-sla-rules/{publicId}ComplaintSlaRulesController.removeComplaintVersionQueryDtoComplaintSlaRulesService.removeJ+RComplaintPriorities_DELETENoneNoneActivity entrycomplaint_sla_rules (hard delete)404, 409configYes (§8.10)
GET /api/complaint-escalation-rulesComplaintEscalationRulesController.listListComplaintEscalationRulesQueryDtoComplaintEscalationRulesService.listJ+RComplaintSettings_READNoneNoneNonecomplaint_escalation_rulesconfigYes (§8.10)
POST /api/complaint-escalation-rulesComplaintEscalationRulesController.createCreateComplaintEscalationRuleDtoComplaintEscalationRulesService.createJ+RComplaintSettings_CREATENoneNoneActivity entrycomplaint_escalation_rules404 COMPLAINT_CATEGORY_NOT_FOUND, 422 COMPLAINT_ESCALATION_RECIPIENT_INVALIDconfigYes (§8.10)
PATCH /api/complaint-escalation-rules/{publicId}ComplaintEscalationRulesController.updateUpdateComplaintEscalationRuleDtoComplaintEscalationRulesService.updateJ+RComplaintSettings_UPDATENoneNoneActivity entrycomplaint_escalation_rulesas create, plus 404 COMPLAINT_ESCALATION_RULE_NOT_FOUND, 409configYes (§8.10)
DELETE /api/complaint-escalation-rules/{publicId}ComplaintEscalationRulesController.removeComplaintVersionQueryDtoComplaintEscalationRulesService.removeJ+RComplaintSettings_DELETENoneNoneActivity entrycomplaint_escalation_rules404, 409configYes (§8.10)
POST /api/complaint-escalation-rules/{publicId}/restoreComplaintEscalationRulesController.restoreComplaintVersionBodyDtoComplaintEscalationRulesService.restoreJ+RComplaintSettings_RESTORENoneNoneActivity entrycomplaint_escalation_rules404, 409configYes (§8.10)
GET /api/complaint-settingsComplaintSettingsController.getComplaintSettingsDtoComplaintSettingsService.getJ+RComplaintSettings_READNoneNoneNonecomplaint_settings, complaint_reminder_settingsconfigYes (§8.10)
PATCH /api/complaint-settingsComplaintSettingsController.updateUpdateComplaintSettingsDtoComplaintSettingsService.updateJ+RComplaintSettings_UPDATENoneNoneActivity entrycomplaint_settings409 COMPLAINT_CONFIG_VERSION_CONFLICTconfigYes (§8.10)
PATCH /api/complaint-settings/reminders/{kind}ComplaintSettingsController.updateReminderUpdateComplaintReminderSettingDtoComplaintSettingsService.updateReminderJ+RComplaintSettings_UPDATENoneNoneActivity entrycomplaint_reminder_settings400 VALIDATION_FAILED, 409configYes (§8.10)
GET /api/complaint-notification-settingsComplaintNotificationSettingsController.listComplaintNotificationSettingDto[]ComplaintNotificationSettingsService.listJ+RComplaintNotificationSettings_READNoneNoneNonecomplaint_notification_settingsconfigYes (§8.10)
PATCH /api/complaint-notification-settings/{eventKey}ComplaintNotificationSettingsController.updateUpdateComplaintNotificationSettingDtoComplaintNotificationSettingsService.updateJ+RComplaintNotificationSettings_UPDATENoneNoneActivity entrycomplaint_notification_settings400 VALIDATION_FAILED, 409configYes (§8.10)
GET /api/mobile/complaints/form-optionsComplaintPortalController.formOptionsComplaintFormOptionsDtoComplaintPortalQueryService.formOptionsJ+RMyComplaints_CREATENoneNoneNoneConfig tables, students, guardians403 AUTH_ACTIVE_ROLE_REQUIRED, 403 COMPLAINT_AUDIENCE_UNRESOLVEDNone foundYes (§8.12)
GET /api/mobile/complaints/student-optionsComplaintPortalController.studentOptionsComplaintPortalSearchQueryDtoComplaintPortalQueryService.studentOptionsJ+RMyComplaints_CREATENoneNoneNonestudents, usersas aboveNone foundYes (§8.12)
GET /api/mobile/complaints/staff-optionsComplaintPortalController.staffOptionsComplaintPortalSearchQueryDtoComplaintPortalQueryService.staffOptionsJ+RMyComplaints_CREATENoneNoneNonestaff, users, designationsas aboveNone foundYes (§8.12)
GET /api/mobile/complaintsComplaintPortalController.listListMyComplaintsQueryDto / ComplaintPortalListItemDto[]ComplaintPortalQueryService.listJ+RMyComplaints_READNoneNoneNonecomplaints, complaint_info_requests400 PAGINATION_LIMIT_INVALIDportalYes (§8.12)
POST /api/mobile/complaintsComplaintPortalController.createCreateMyComplaintDto / ComplaintPortalCommandResultDtoComplaintPortalDraftService.createJ+RMyComplaints_CREATENoneOutbox (with submit)submitted (with submit)complaints, complaint_events§8.11portalYes (§8.11)
GET /api/mobile/complaints/{id}ComplaintPortalController.detailComplaintPortalDetailDtoComplaintPortalQueryService.detailJ+RMyComplaints_READNoneNoneNoneAll complaint tables404 COMPLAINT_NOT_FOUNDportalYes (§8.12)
PATCH /api/mobile/complaints/{id}ComplaintPortalController.updateUpdateMyComplaintDtoComplaintPortalDraftService.updateJ+RMyComplaints_CREATENoneNoneNonecomplaints404, 409 COMPLAINT_NOT_DRAFT, 409 COMPLAINT_VERSION_CONFLICTNone foundYes (§8.12)
DELETE /api/mobile/complaints/{id}ComplaintPortalController.removeexpectedVersion queryComplaintPortalDraftService.removeJ+RMyComplaints_CREATENoneNoneNonecomplaints, complaint_attachments (cascade), storage404, 409 COMPLAINT_NOT_DRAFT, 409 COMPLAINT_VERSION_CONFLICTportalYes (§8.12)
POST /api/mobile/complaints/{id}/submitComplaintPortalController.submitComplaintExpectedVersionDtoComplaintPortalDraftService.submitJ+RMyComplaints_CREATENoneOutboxsubmitted; submitted_complainant, submitted_triagecomplaints, complaint_events§8.11, 409 COMPLAINT_NOT_DRAFT, 409 COMPLAINT_SUBMITTER_KIND_CHANGEDportalYes (§8.12)
POST /api/mobile/complaints/{id}/information-requests/{requestId}/respondComplaintPortalActionsController.respondComplaintPortalRespondDtoComplaintPortalActionsService.respondToInformationRequestJ+RMyComplaints_UPDATENoneOutboxinformation_provided; information_provided_assigneecomplaints, complaint_info_requests, complaint_events404, 404 COMPLAINT_INFORMATION_REQUEST_NOT_FOUND, 409portalYes (§8.12)
POST /api/mobile/complaints/{id}/confirm-resolutionComplaintPortalActionsController.confirmResolutionComplaintExpectedVersionDtoComplaintPortalActionsService.confirmResolutionJ+RMyComplaints_UPDATENoneNoneclosedcomplaints, complaint_events404, 409portalYes (§8.12)
POST /api/mobile/complaints/{id}/reopenComplaintPortalActionsController.reopenComplaintPortalReasonDtoComplaintPortalActionsService.reopenJ+RMyComplaints_UPDATENoneOutboxreopened; reopened_handlerscomplaints, complaint_resolutions, complaint_events404, 409 COMPLAINT_REOPEN_NOT_ALLOWED, 409 COMPLAINT_REOPEN_WINDOW_EXPIRED, 409portalYes (§8.12)
POST /api/mobile/complaints/{id}/cancelComplaintPortalActionsController.cancelComplaintPortalReasonDtoComplaintPortalActionsService.cancelJ+RMyComplaints_UPDATENoneOutboxcancelled; cancelled_handlerscomplaints, complaint_info_requests, complaint_events404, 409 COMPLAINT_CANCEL_NOT_ALLOWED, 409portal, workflowYes (§8.12)
POST /api/mobile/complaints/{id}/attachmentsComplaintPortalAttachmentsController.uploadAttachmentComplaintPortalAttachmentUploadDto / ComplaintAttachmentDtoComplaintAttachmentService.uploadForPortalJ+RMyComplaints_UPDATENoneNoneattachment_added for a loose filecomplaint_attachments, complaint_events, storage404, §8.8attachmentsYes (§8.12)
GET /api/mobile/complaints/{id}/attachments/{attachmentId}ComplaintPortalAttachmentsController.downloadAttachmentBinaryComplaintAttachmentDownloadService.downloadForPortalJ+RMyComplaints_READNoneNoneNonecomplaint_attachments, storage404 COMPLAINT_NOT_FOUND, 404 COMPLAINT_ATTACHMENT_NOT_FOUNDattachmentsYes (§8.12)

82 rows.

13.2 Request/Response Exhaustiveness

Example TypeWhere
Minimal valid request§8.4 (assign), §8.11 (draft)
Full valid request§8.2 (office record), §8.10 (category), §8.11 (anonymous submit)
Authenticated requestEvery 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 error400 VALIDATION_FAILED with the field message, for example a title shorter than 3 characters after normalisation
Domain errorEvery family's error table
Auth and permission error401 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

DiagramLocation
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 flowNot applicable: no route caches
Async/job flowBelow
Lifecycle state diagramFeatures and flows

13.4 Consumer Integration Notes

ConsumerRequired KnowledgeFailure HandlingContract Stability
Admin panelSend expectedVersion from the last read on every command. Render buttons from allowedActions and access, never from status. Render every instant in schoolTimezoneCOMPLAINT_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 sameStable
Portal screensGenerate 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 minutes429 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 reopenDeadlineStable
Bulk UIEach item succeeds or fails aloneShow per-item errorCode; refresh versions from results[].versionStable
QAfindOne, 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 submitterCompare responses for anonymous and non-anonymous fixturesStable
Internal (sweep)Not an HTTP consumerIdempotency lives in complaint_automation_log, not in job retriesStable

13.5 API Tradeoffs and Rationale

DecisionChosen BehaviorAlternatives ConsideredWhy This TradeoffRiskMitigation
One route per actionPOST /api/complaints/{id}/assign, /resolve and so onOne PATCH with a status fieldEach action has its own permission, body and side effects, and a status field would let a client request a transition the state machine forbidsMore routes§13.1 lists them all; the structure test fingerprints them
Optimistic concurrencyexpectedVersion on every mutationLast write winsTwo handlers, or a handler and a complainant, must not overwrite each other silentlyA stale client is refused409 COMPLAINT_VERSION_CONFLICT; reload
Allowlisted readsDetail and events resolve access per complaint instead of one route permissionOne route permission"The whole register or my own assignment" is not one permissionThe allowlist skips RoleGuard's active-role checkpermissionsOf refuses a missing active role
404 for invisibleInvisible and missing answer the same403 for invisibleAn id space of grievances must not become an enumeration oracleHarder debuggingVisibility rules are documented in §3 and §5
Anonymity as maskingIdentity is stored; admin reads mask it; one audited route reveals itNot storing the identityThe school must be able to notify the complainant and, when authorised, identify themA raw table read shows the identityReveal needs a superadmin-only code and writes identity_viewed
No notification detailNotices carry the reference and fixed wording onlyInclude the titleA title can name a child or a colleague on a lock screenLess context in the noticeThe link opens the complaint
Unpaginated lists refusedpagination=false answers 400 on the register and portalAllow with a capA full grievance list in one response is a bulk export by another nameClients must pagesize up to 100
Bulk as repeated single commandsEach item runs the single command in its own transactionOne set-based UPDATEA bulk path cannot drift from the single path's rulesSlower for 100 itemsCap of 100

13.6 API Change Impact

ChangeAffected ConsumersBackend ImpactData ImpactMigration Needed?Compatibility Plan
Adding a ComplaintStatus valuePanel, portalLifecycle util, list views, statistics byStatusALTER TYPE ... ADD VALUE; the status CHECKs are implications and need no rewriteYesClients must tolerate an unknown status
Renaming an action routePanelController, structure baselineNoneNoKeep the old path until clients move
Adding a response fieldPanel, portalMapper and DTONoneNoAdditive
Removing or renaming a response fieldPanel, portalMapper and DTONoneNoCoordinate a release with the panel
Changing an error codePanel, portalError registry and throw sitesNoneNoClients branch on codes, so treat as breaking
Adding a notification event keyPanel settings screenCHECK constraint, seeds, templatesNew complaint_notification_settings rowYesSeed it disabled or enabled by decision
Adding a permission modulePanel role screen, activity filterCatalogue, seeds, permissions:syncGrantsNo schema changeThe 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/mobile prefix, 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

On this page

Complaints - API Reference1. Documentation Evidence2. Module Summary3. Concepts and Terminology4. API Surface Map5. Auth, Identity, and Permissions5.1 Permission modules5.2 Seeded grants5.3 Validation order on every admin command6. DTO and Model Reference6.1 Response: ComplaintListItemDto6.2 Response: ComplaintDetailDto (extends ComplaintListItemDto)6.3 Response: ComplaintEventPageDto6.4 Response: ComplaintCommandResultDto and ComplaintPortalCommandResultDto6.5 Response: ComplaintPortalListItemDto and ComplaintPortalDetailDto6.6 Response: ComplaintFormOptionsDto6.7 Response: statistics, bulk, picker and identity DTOs6.8 Request: CreateMyComplaintDto and UpdateMyComplaintDto (portal)6.9 Request: RecordComplaintDto (POST /api/complaints)6.10 Request: workflow action DTOs6.11 Request: bulk DTOs6.12 Request: attachment uploads (multipart/form-data)6.13 Request: query DTOs6.14 Request: configuration DTOs7. Enum Reference8. Endpoint Reference8.1 GET /api/complaintsPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.2 POST /api/complaints (office record)8.3 GET /api/complaints/{id} and GET /api/complaints/{id}/events8.4 POST /api/complaints/{id}/assign (workflow action family)PurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.5 The other workflow actions8.6 POST /api/complaints/{id}/identity-reveal8.7 POST /api/complaint-bulk/{action}8.8 POST /api/complaints/{id}/attachments and GET /api/complaints/{id}/attachments/{attachmentId}UploadDownload8.9 GET /api/complaint-assignable-users and GET /api/complaint-statistics8.10 Configuration familiesWorked example: POST /api/complaint-categories8.11 POST /api/mobile/complaints (portal filing family)PurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.12 The rest of the portal9. Flow Diagrams9.1 Route Ownership9.2 Request Sequence: workflow command9.3 Request Sequence: portal filing9.4 Error Branch9.5 Auth and permission flow for allowlisted handlers9.6 Data contract map10. Pagination, Sorting, Filtering, and Search11. Caching, Jobs, and External Integrations13. Mandatory Deep API Documentation Pack13.1 Route-by-Route Completeness Matrix13.2 Request/Response Exhaustiveness13.3 API Diagram Pack13.4 Consumer Integration Notes13.5 API Tradeoffs and Rationale13.6 API Change Impact14. Zero-Omission API Checklist15. Integration ChecklistSee Also