Grievances
The anonymous grievance channel: raising, reading and answering grievances, and the super-admin queue.
The anonymous grievance channel: raising, reading and answering grievances, and the super-admin queue.
Generated from the OpenAPI spec by
npm run docs:api -w api. Do not edit this page — change the decorators inapps/api/src/and regenerate.
Conventions
- Auth — a session on every route: the cookie on web, the bearer token on mobile. See Authentication.
- Errors — every non-2xx response has the body described in Errors.
- Types — a
?after a field name means it is optional;| nullmeans it is present but may be null. - Guide — Grievance Channel walkthrough.
Endpoints
POST /api/v1/grievances — Raise a grievance
Open to anyone signed in. Filed as the signed-in caller — there is no way to name a different submitter, and sending submittedById is rejected with a 400. No response anywhere in this API ever says who raised it, to any role, ever. The grievance itself is visible to every employee the moment it is filed; only the identity is private. Attachments are not supported yet.
Body — CreateGrievanceDto
Returns 201 → GrievanceResponse
| Error | When |
|---|---|
400 | The request was rejected. message is an array of field errors when validation failed. |
401 | No valid session. Clear the stored token and sign in again. |
403 | Signed in, but not allowed to do this. |
GET /api/v1/grievances — The grievance board
Every grievance raised in the company, newest first by default, each one attributed to "Anonymous". Filter with category and status. There is no "only mine" filter, deliberately — see the note on the query DTO. One employee’s board is identical to another’s, apart from unread. total counts the whole filtered set, not the page.
| Query | Type | Required | Notes |
|---|---|---|---|
limit | number | no | Rows per page. Defaults to 20, capped at 50. |
status | open | in_review | resolved | closed | no | |
category | harassment | discrimination | workplace_safety | compensation | management | workload | policy | facilities | other | no | |
sort | latest | oldest | no | |
cursor | string | no | The nextCursor from the previous page. Omit for the first. |
Returns 200 → Page<GrievanceResponse> — see Paging
| Error | When |
|---|---|
400 | The request was rejected. message is an array of field errors when validation failed. |
401 | No valid session. Clear the stored token and sign in again. |
403 | Signed in, but not allowed to do this. |
GET /api/v1/grievances/unread-count — How many grievances you have not opened
For the sidebar badge. Counts grievances you did not raise and have not yet marked read. Per-person: another employee opening one does not change your count, and yours never counts your own.
Returns 200 → CountResponse
| Error | When |
|---|---|
401 | No valid session. Clear the stored token and sign in again. |
403 | Signed in, but not allowed to do this. |
GET /api/v1/grievances/{grievanceId} — One grievance
Any grievance, raised by anybody. The submitter is not named, here or anywhere.
| Path parameter | Type | Notes |
|---|---|---|
grievanceId | string |
Returns 200 → GrievanceResponse
| Error | When |
|---|---|
401 | No valid session. Clear the stored token and sign in again. |
403 | Signed in, but not allowed to do this. |
404 | No such grievance. |
POST /api/v1/grievances/{grievanceId}/read — Mark a grievance read
Call it when the thread opens. Read state is yours alone — it never marks the grievance read for anybody else — and calling it twice keeps the first time.
| Path parameter | Type | Notes |
|---|---|---|
grievanceId | string |
Returns 204 → no body
| Error | When |
|---|---|
401 | No valid session. Clear the stored token and sign in again. |
403 | Signed in, but not allowed to do this. |
404 | No such grievance. |
GET /api/v1/grievances/{grievanceId}/replies — The thread on a grievance
Oldest first, so it reads as a conversation. Every message is attributed to "Anonymous" — the person who raised it, colleagues answering, and the grievance team alike. authorRole says which side wrote it without naming anybody.
| Path parameter | Type | Notes |
|---|---|---|
grievanceId | string |
| Query | Type | Required | Notes |
|---|---|---|---|
cursor | string | no | The nextCursor from the previous page. Omit for the first. |
limit | number | no | Rows per page. Defaults to 20, capped at 50. |
Returns 200 → Page<GrievanceReplyResponse> — see Paging
| Error | When |
|---|---|
401 | No valid session. Clear the stored token and sign in again. |
403 | Signed in, but not allowed to do this. |
404 | No such grievance. |
POST /api/v1/grievances/{grievanceId}/replies — Reply to a grievance
Answer any grievance on the board — your own or a colleague’s. The reply is anonymous like everything else here; there is no per-reply choice, and sending one is a 400. Tag a colleague by putting an @[userId] token in body, the same format the feed uses: they are notified that they were mentioned in an anonymous grievance, and you stay anonymous to them. Adding a reply does not move the status or reorder the board. No attachments.
| Path parameter | Type | Notes |
|---|---|---|
grievanceId | string |
Body — CreateGrievanceReplyDto
Returns 201 → GrievanceReplyResponse
| Error | When |
|---|---|
400 | The request was rejected. message is an array of field errors when validation failed. |
401 | No valid session. Clear the stored token and sign in again. |
403 | Signed in, but not allowed to do this. |
404 | No such grievance. |
GET /api/v1/grievances/{grievanceId}/status-changes — How a grievance has been handled
Every status move with the reason given for it, newest first, starting from the entry that records it being raised. This is what answers “why is this still open” — show it wherever you show the status. Each entry is attributed to the grievance team, never to an individual handler.
| Path parameter | Type | Notes |
|---|---|---|
grievanceId | string |
Returns 200 → GrievanceStatusChangeResponse[]
| Error | When |
|---|---|
401 | No valid session. Clear the stored token and sign in again. |
403 | Signed in, but not allowed to do this. |
404 | No such grievance. |
PATCH /api/v1/grievances/{grievanceId}/replies/{replyId} — Edit one of your own messages
Only the person who wrote it — employee or grievance team alike. The message is marked edited (editedAt), and anybody newly tagged is told. Anyone else gets a 403, which says nothing about who did write it.
| Path parameter | Type | Notes |
|---|---|---|
grievanceId | string | |
replyId | string |
Body — CreateGrievanceReplyDto
Returns 200 → GrievanceReplyResponse
| Error | When |
|---|---|
400 | The request was rejected. message is an array of field errors when validation failed. |
401 | No valid session. Clear the stored token and sign in again. |
403 | Signed in, but not allowed to do this. |
404 | No such message on that grievance. |
DELETE /api/v1/grievances/{grievanceId}/replies/{replyId} — Delete one of your own messages
Only the person who wrote it. Removed from the thread for everybody, for good.
| Path parameter | Type | Notes |
|---|---|---|
grievanceId | string | |
replyId | string |
Returns 204 → no body
| Error | When |
|---|---|
401 | No valid session. Clear the stored token and sign in again. |
403 | Signed in, but not allowed to do this. |
404 | No such message on that grievance. |
GET /api/v1/admin/grievances — Every grievance
Grievance handlers (super admins) only — the same grievances every employee sees at /api/v1/grievances, in the same shape, with the filters somebody working through a queue wants. Nobody is named here either.
| Query | Type | Required | Notes |
|---|---|---|---|
limit | number | no | Rows per page. Defaults to 20, capped at 50. |
status | open | in_review | resolved | closed | no | |
category | harassment | discrimination | workplace_safety | compensation | management | workload | policy | facilities | other | no | |
sort | latest | oldest | no | Newest or oldest first. Defaults to latest. |
cursor | string | no | The nextCursor from the previous page. Omit for the first. |
Returns 200 → Page<GrievanceResponse> — see Paging
| Error | When |
|---|---|
400 | The request was rejected. message is an array of field errors when validation failed. |
401 | No valid session. Clear the stored token and sign in again. |
403 | Signed in, but not allowed to do this. |
GET /api/v1/admin/grievances/{grievanceId} — One grievance
Same shape as a queue row. Anonymous grievances still have submitter: null.
| Path parameter | Type | Notes |
|---|---|---|
grievanceId | string |
Returns 200 → GrievanceResponse
| Error | When |
|---|---|
401 | No valid session. Clear the stored token and sign in again. |
403 | Signed in, but not allowed to do this. |
404 | No such grievance. |
PATCH /api/v1/admin/grievances/{grievanceId}/status — Move a grievance to a new status, and say why
open → in_review → resolved / closed, in any order — a closed grievance can be reopened. resolvedAt is set on the first move to resolved or closed and cleared on a reopen. reason is required. It is shown to the person who raised the grievance, attributed to the grievance team rather than to you, and kept on the handling record — a status that changes with nothing said is the thing this channel is least able to afford.
| Path parameter | Type | Notes |
|---|---|---|
grievanceId | string |
Body — UpdateGrievanceStatusDto
Returns 200 → GrievanceResponse
| Error | When |
|---|---|
400 | The request was rejected. message is an array of field errors when validation failed. |
401 | No valid session. Clear the stored token and sign in again. |
403 | Signed in, but not allowed to do this. |
404 | No such grievance. |
GET /api/v1/admin/grievances/{grievanceId}/status-changes — How a grievance has been handled
Every status move with the reason given for it, newest first, starting from the entry that records it being raised. Carries changedBy so the team can see who made each call; the employee’s copy of this record does not.
| Path parameter | Type | Notes |
|---|---|---|
grievanceId | string |
Returns 200 → AdminGrievanceStatusChangeResponse[]
| Error | When |
|---|---|
401 | No valid session. Clear the stored token and sign in again. |
403 | Signed in, but not allowed to do this. |
404 | No such grievance. |
GET /api/v1/admin/grievances/{grievanceId}/replies — The thread on a grievance
Oldest first. A handler’s reply carries author so the team can see which of them answered; an employee’s reply never does, whether they raised the grievance or are answering it.
| Path parameter | Type | Notes |
|---|---|---|
grievanceId | string |
| Query | Type | Required | Notes |
|---|---|---|---|
cursor | string | no | The nextCursor from the previous page. Omit for the first. |
limit | number | no | Rows per page. Defaults to 20, capped at 50. |
Returns 200 → Page<AdminGrievanceReplyResponse> — see Paging
| Error | When |
|---|---|
401 | No valid session. Clear the stored token and sign in again. |
403 | Signed in, but not allowed to do this. |
404 | No such grievance. |
POST /api/v1/admin/grievances/{grievanceId}/replies — Answer a grievance
Adds a handler’s reply to the thread. The person who raised it sees it attributed to the grievance team, not to you by name. Replying does not move the status — do that explicitly, so the queue keeps saying what is true.
| Path parameter | Type | Notes |
|---|---|---|
grievanceId | string |
Body — CreateGrievanceReplyDto
Returns 201 → AdminGrievanceReplyResponse
| Error | When |
|---|---|
400 | The request was rejected. message is an array of field errors when validation failed. |
401 | No valid session. Clear the stored token and sign in again. |
403 | Signed in, but not allowed to do this. |
404 | No such grievance. |
Data models
CreateGrievanceDto
| Field | Type | Notes |
|---|---|---|
subject | string | ≤ 150 chars |
description | string | ≤ 5000 chars |
category | harassment | discrimination | workplace_safety | compensation | management | workload | policy | facilities | other | |
anonymous? | boolean | Kept for the clients that still send it, and for the column, but there is only one answer now: every grievance on the board is anonymous, and omitting this is the same as sending true. Sending false no longer puts a name anywhere — no route returns one. |
GrievanceResponse
| Field | Type | Notes |
|---|---|---|
id | string | |
submitterName | string | Who to show it as. Always the literal "Anonymous", for every grievance and every viewer — the person who raised it included. Display it as given: do not substitute "You", and do not look the submitter up, because no route will tell you. |
unread | boolean | Whether you have opened it yet. Drives the sidebar count; mark it read with POST /api/v1/grievances/{grievanceId}/read when the thread opens. Per-person: another employee reading it does not change yours. |
subject | string | |
description | string | |
category | harassment | discrimination | workplace_safety | compensation | management | workload | policy | facilities | other | |
anonymous | boolean | Whether it was filed without a name attached. |
status | open | in_review | resolved | closed | |
resolvedAt | string ISO 8601 | null | When it was first resolved or closed. Null while open or in review, and after a reopen. |
replyCount | number | How many messages are on its thread. Read the thread itself from the /replies route; this is here so a list row can show the number without it. |
statusNote | string | null | Why it is at the status it is at — the most recent reason a handler gave. Show it with the status; a status on its own reads as nobody having looked. The whole record is on the /status-changes route. |
statusNoteAt | string ISO 8601 | null | |
createdAt | string ISO 8601 | |
updatedAt | string ISO 8601 |
CountResponse
| Field | Type | Notes |
|---|---|---|
count | number |
GrievanceReplyResponse
| Field | Type | Notes |
|---|---|---|
id | string | |
grievanceId | string | |
authorName | string | Who to show it as. Always the literal "Anonymous" — the submitter’s replies, a colleague’s, and the grievance team’s alike. Nobody on a grievance thread is named to anybody. |
authorRole | submitter | handler | Which side wrote it: the person who raised it, a colleague on the board, or the grievance team. It names a side, never a person — every message is drawn the same way and labelled "Anonymous". |
body | string | |
mentions | MentionResponse[] | People tagged in body, resolved for display. The body carries each one as an @[id] token — render it with the same component the feed uses. These name the colleague who was tagged; the author stays anonymous. |
createdAt | string ISO 8601 | |
editedAt | string ISO 8601 | null | When its author last changed it; null if never. Show "edited". |
isMine | boolean | Whether you wrote it — offer edit and delete on these, and only these. Never says who wrote any other message. |
MentionResponse
| Field | Type | Notes |
|---|---|---|
id | string | |
name | string | |
image | string | null |
CreateGrievanceReplyDto
| Field | Type | Notes |
|---|---|---|
body | string | ≤ 5000 chars |
GrievanceStatusChangeResponse
| Field | Type | Notes |
|---|---|---|
id | string | |
grievanceId | string | |
fromStatus | open | in_review | resolved | closed | null | null on the entry that records it being raised. |
toStatus | open | in_review | resolved | closed | |
reason | string | |
changedByName | string | Always "Grievance team" here — individual handlers are not named to the person who raised the grievance, on the record any more than on the thread. |
createdAt | string ISO 8601 |
UpdateGrievanceStatusDto
| Field | Type | Notes |
|---|---|---|
status | open | in_review | resolved | closed | |
reason | string | ≤ 1000 chars |
AdminGrievanceStatusChangeResponse
| Field | Type | Notes |
|---|---|---|
id | string | |
grievanceId | string | |
fromStatus | open | in_review | resolved | closed | null | null on the entry that records it being raised. |
toStatus | open | in_review | resolved | closed | |
reason | string | |
changedByName | string | Always "Grievance team" here — individual handlers are not named to the person who raised the grievance, on the record any more than on the thread. |
createdAt | string ISO 8601 | |
changedBy | GrievanceSubmitterResponse | null | The handler who made the move. null on the entry that records it being raised, and when the handler’s account has been deleted. |
GrievanceSubmitterResponse
| Field | Type | Notes |
|---|---|---|
id | string | |
name | string | |
email | string | |
image | string | null |
AdminGrievanceReplyResponse
| Field | Type | Notes |
|---|---|---|
id | string | |
grievanceId | string | |
authorName | string | Who to show it as. Always the literal "Anonymous" — the submitter’s replies, a colleague’s, and the grievance team’s alike. Nobody on a grievance thread is named to anybody. |
authorRole | submitter | handler | Which side wrote it: the person who raised it, a colleague on the board, or the grievance team. It names a side, never a person — every message is drawn the same way and labelled "Anonymous". |
body | string | |
mentions | MentionResponse[] | People tagged in body, resolved for display. The body carries each one as an @[id] token — render it with the same component the feed uses. These name the colleague who was tagged; the author stays anonymous. |
createdAt | string ISO 8601 | |
editedAt | string ISO 8601 | null | When its author last changed it; null if never. Show "edited". |
isMine | boolean | Whether you wrote it — offer edit and delete on these, and only these. Never says who wrote any other message. |
author | GrievanceSubmitterResponse | null | The handler who wrote it, so the team can see which of them answered. Always null on any employee’s reply — neither the submitter nor a colleague on the board is ever named. Also null when the handler’s account has been deleted. |