Helpdesk
Any signed-in user can report a problem: a subject, a category, a description and up to five screenshots, dropped, picked or pasted (Ctrl+V). Staff - the platform admins, and an organization’s admins for the reporters in their reach - work the queue, reply, leave internal notes and change the status. Every change notifies the other side in-app, and the reporter by mail too, in the same transaction as the change.
1. What people see
- Reporters: “Report a problem” in the user menu opens a dialog on the
current page, and that page’s path is captured without query or fragment.
“My tickets” (
/support) lists their tickets. A ticket page (/support/:id) shows the conversation and has a reply box and “Close ticket”. Staff appear as “Platform support” or “<organization>admin” (the ticket’s organization). A reply on a resolved ticket reopens it. A closed ticket takes no replies. - Staff: “Helpdesk” in the sidebar (
/helpdesk) opens the queue (a platform admin, orHelpdesk.Readorganization-wide or in a unit). It shows open and in-progress tickets by default, with search by subject, reporter email or#number, filters by status and category from those columns’ headers, and the filter bar above the table (for a platform admin Organizations, then Units (“Organization / Unit”) and Members once an organization is chosen, sent asunit[]andmember[]; removing the organization clears both; units and members for organization staff who also holdMembers.Read; see Admin pages per level). A ticket (/helpdesk/:id) shows internal notes (amber, with a lock). The side panel has the reporter, the captured page, browser and app version (copyable), the status and, for a platform admin, Delete. The reply box and the status show only to staff holdingHelpdesk.ReplyandHelpdesk.UpdateStatus. The first public reply movesOPENtoIN_PROGRESS. An internal note is visible to staff only: it notifies nobody and does not change the ticket’s last activity (the queue does not re-sort on a note). - Notifications:
HELPDESK_TICKET_CREATED(to staff),HELPDESK_REPORTER_REPLIED(to the staff who took part, else all staff; both to the staff who can open the ticket, section 2),HELPDESK_STAFF_REPLIEDandHELPDESK_STATUS_CHANGED(to the reporter, also mailed). The mail never contains a message, only#number, the subject and a link.
2. Who is staff
Two kinds of staff answer tickets:
- Platform admins (the
users.isPlatformAdminflag) through/api/helpdesk/tickets(@RequirePlatformAdmin()): every ticket, every note, and the only ones who delete a ticket. - Organization staff through
/api/organization/helpdesk/tickets(@Entity({ name: 'Helpdesk', units: true }), needs an active organization): the tickets of the active organization whose reporter is in their reach - a live member of the organization (Helpdesk.Readheld organization-wide) or of a unit their grant covers, sub-units included. A reporter who left the organization is out of every organization’s reach; their tickets stay with the platform. A route acting on one ticket also needs its own permission over that reporter:Helpdesk.Replyheld in unit A andHelpdesk.Readin units A and B answers the tickets of A only. The built-inORG_ADMINandUNIT_ADMINhold the four permissions.
| Route | Permission |
|---|---|
GET /api/organization/helpdesk/tickets | Helpdesk.Read |
GET /api/organization/helpdesk/tickets/:id | Helpdesk.ReadOne |
POST /api/organization/helpdesk/tickets/:id/messages | Helpdesk.Reply |
PATCH /api/organization/helpdesk/tickets/:id | Helpdesk.UpdateStatus |
The list takes the platform queue’s search, status[], category, sort and
paging, plus unit[] and member[] (people in reach, see
Admin pages per level). A ticket
out of reach - another organization’s, deleted, or of a reporter not in
reach - is 404 NOT_FOUND. There is no delete.
Who wrote it. Each staff message records which staff wrote it
(TicketMessage.staffScope: PLATFORM or ORGANIZATION; null on the
reporter’s own messages; asStaff stays true for both). Responses carry it
as author.staffKind (platform or organization) with
author.organizationName (the ticket’s organization) for an organization
staff message. The reporter sees the label in place of the name (“Platform
support” or “<organization> admin”) and never the staff’s identity: no
author id or email, and a staff author with no name shows as “Staff”;
staff see the name with the label next to it.
Internal notes. A platform note (internal, PLATFORM) is hidden from
organization staff: not in the ticket, not in the list’s message count, and
its images answer 404. An organization note is visible to both kinds of
staff. Neither is ever shown to the reporter.
Never above. Organization staff whose reach is a list of units (a unit
admin) read an organization note only when its author is in their reach (or is
themselves), so an organization admin’s note is hidden from a unit admin: not
in the ticket, not in the message count, and its images answer 404. The
replies of any staff outside their reach (organization admins, platform admins,
staff of a sibling unit) show with the label and name as the reporter sees
them, without email or identity: no author id, and an author with no name
shows as “Staff”, never their email. Organization-wide staff and the platform
admin see every note and every author.
Images. GET /api/support/attachments/:id serves the reporter (public
messages of their own ticket), a platform admin (any image of a live ticket),
and organization staff (Helpdesk.Read) for a ticket of their active
organization whose reporter is in their reach, never an image of a platform
note (nor, for a unit admin, of a note written outside their reach).
Notifications. New tickets and reporter replies notify the live platform
admins and the organization’s staff who hold Helpdesk.Read with the reporter
in their reach. An organization staff member’s notification carries
data.organizationId (the ticket’s), so opening it switches into that
organization, where the ticket is in reach; a platform admin’s carries none
(a platform admin never switches).
Audit. A status change by organization staff
(HELPDESK_TICKET_STATUS_CHANGED, also the automatic OPEN → IN_PROGRESS
on the first public reply) is recorded with the active organization, so it
shows in that organization’s audit log; a platform admin’s has no
organization, as before.
Tickets carry the organization the reporter was acting in when they sent it (Ticket.organizationId, taken from the session’s active organization; null when they had none). The platform queue shows an Organization column and organization[], unit[] and member[] filters (the staff ticket response carries it too). A reporter always sees only their own tickets, whichever organization is active, and organizationId has no relation, so a ticket outlives its organization. A ticket with no organization is answered by the platform only.
3. Storage
Images are stored by @core/api-storage, under helpdesk/<ticketId>/<attachmentId>.
| Setup | Settings |
|---|---|
| Local disk (dev) | nothing: tmp/storage under the working directory, or STORAGE_DIR (.env.example: tmp/storage/<product>) |
| Single host (prod) | STORAGE_DRIVER=local, STORAGE_DIR=/var/lib/<product>/storage on a persistent volume (required with NODE_ENV=production) |
| AWS S3 | STORAGE_DRIVER=s3, S3_BUCKET, S3_REGION; credentials from the instance role, or S3_ACCESS_KEY_ID + S3_SECRET_ACCESS_KEY |
| MinIO | as S3, plus S3_ENDPOINT=http://localhost:9000 and S3_FORCE_PATH_STYLE=true |
| Cloudflare R2 | as S3, with S3_REGION=auto and S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com |
Every key (all read once at boot; a bad value stops the API with a message):
| Key | Default | Meaning |
|---|---|---|
STORAGE_DRIVER | local | local or s3 |
STORAGE_DIR | tmp/storage | local only; required with NODE_ENV=production, absolute path on a persistent volume |
S3_BUCKET, S3_REGION | required with STORAGE_DRIVER=s3 (auto for R2) | |
S3_ENDPOINT | AWS | absolute http(s) URL (MinIO, R2) |
S3_FORCE_PATH_STYLE | false | true for MinIO |
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY | both or neither; neither uses the SDK’s default credential chain. The secret is never logged |
Upgrading an existing production deployment: its .env must gain
STORAGE_DIR (or STORAGE_DRIVER=s3 with S3_BUCKET and S3_REGION) before
this release is deployed. With NODE_ENV=production and the local driver the
API refuses to boot without STORAGE_DIR.
pnpm nx serve keelmint-api -c production sets NODE_ENV=production: with an
existing .env that has no STORAGE_DIR it refuses to boot too. Add
STORAGE_DIR (or STORAGE_DRIVER=s3) to that file.
Things to know about the local driver:
- The API needs write access to
STORAGE_DIRand everything inside it. The driver checks each intermediate directory (not a symlink, inside the root) and then uses it; between the check and the use there is a small window in which someone with write access insideSTORAGE_DIRcould swap a directory for a symlink. KeepSTORAGE_DIRwritable by the API user only. - Files are never public. There is no static route and no presigned URL: every image is streamed through the API after an authorization check (section 4). Keep an S3 bucket private for the same reason.
- Back up the directory or the bucket together with the database. A database restore without its files leaves rows whose images answer 404.
- Soft-deleted tickets keep their files on purpose (an undelete in the database brings them back). Nothing deletes a file except the request that stored it, when its database write fails.
MinIO for local S3 testing (not part of docker-compose.yml):
minio: image: minio/minio command: server /data --console-address ":9001" ports: ['9000:9000', '9001:9001'] environment: MINIO_ROOT_USER: minio MINIO_ROOT_PASSWORD: minio-secretCreate the bucket in the console (http://localhost:9001) and keep it private.
Production endpoints use https:. Several products: one bucket prefix or one
directory per product (Your product).
4. Limits and what happens to an image
-
5 images per message, 5 MB each (exactly 5 MB is accepted), 50 per ticket. Subject 1–200 characters; message 0–10,000 characters, with a message or an image required.
-
Per user: 5 new tickets and 30 messages per hour (in memory, per API instance), and 100 MB of images per rolling 24 hours (in the database). Only the 24-hour quota is counted from rows, and it includes deleted attachments. The hourly limits are in-memory throttles, unaffected by deletes.
-
The type is read from the bytes (PNG, JPEG, WebP, GIF). SVG, HEIC, AVIF, TIFF, BMP, PDF and everything else get 415. The name and the declared type are ignored; the stored name is sanitized and gets the sniffed extension.
-
Metadata is stripped without re-encoding:
- JPEG: Exif (GPS included), XMP, IPTC, MPF and comments, and anything after the end of the main image (phones append a second image there). The segments are validated strictly: a malformed file is refused (an empty DHT/DQT/DAC table is valid). The JFIF thumbnail and JFXX extension thumbnails are removed, because a thumbnail can show the uncropped image;
- PNG: an allowlist. Only the image chunks (
IHDR,PLTE,IDAT,IEND,tRNS, APNGacTL/fcTL/fdAT) and colour chunks (gAMA,cHRM,sRGB,iCCP,sBIT,bKGD,hIST,pHYs,cICP,mDCV,cLLI) survive. Text chunks,eXIf,tIMEand every unknown or private chunk are dropped; - WebP:
EXIFandXMP; - GIF: comments.
ICC colour profiles are kept (colour, no location or identity). A JPEG keeps only its Orientation tag (when it is not 1), in a minimal Exif block, so photos are not shown rotated.
-
The client cancels an upload that has not finished after min(290 s, max(120 s, payload size / 32 KiB/s)) and shows “The upload took too long”. The cap is just under Node’s server
requestTimeout(300 s), which cuts any request still sending its body. Roughly 9 MB fits the window at 32 KiB/s, so on a slow link send fewer or smaller images. A request that timed out may still have been created on the server, so retrying after a timeout can create a duplicate; check the conversation first. -
Images are served only through
GET /api/support/attachments/:id, after an authorization check. The headers areContent-Disposition: attachment,X-Content-Type-Options: nosniffandCache-Control: private, no-store. There are no public or presigned URLs.
5. Failure modes
| What breaks | What happens |
|---|---|
| Storage is down (S3 unreachable, disk full) | 500 on upload; nothing is written; the client keeps the form and the images. Downloads answer 500. |
| The database write fails after the upload | The request deletes the objects it stored (logged at warn with the keys) and answers the original error. |
| The process dies between upload and commit | An orphan object with no row: never served (serving needs a row). See the sweep below. |
| A row exists but its object is missing | 404, logged at error with the key; the client shows “Image unavailable”. |
| More than 100 staff users | Notifications reach the 100 oldest staff accounts only (platform admins and the organization’s staff together). |
| Several API instances | Each instance has its own throttle counters; the 24-hour quota still holds (database). |
6. Orphan sweep (manual)
A crash between the upload and the database commit leaves objects with no row. They are never served and nothing removes them automatically.
List the keys under helpdesk/ (aws s3 ls s3://<bucket>/helpdesk/ --recursive,
or find "$STORAGE_DIR/helpdesk" -type f -mtime +1). Delete the ones older
than a day that have no row:
SELECT "storageKey" FROM ticket_attachments WHERE "storageKey" = ANY($1);Deleted tickets keep their objects on purpose: an undelete in the database brings the images back.
7. Production checklist
-
STORAGE_DRIVER=s3with a private bucket, orSTORAGE_DIRon a persistent volume (single host only; several instances needs3). - Backups include the bucket or the directory, not only the database.
- Existing production
.envfiles gainedSTORAGE_DIR(ors3) before this release: the API refuses to boot in production without it. - The per-user throttles (5 tickets, 30 messages per hour) do not depend on
TRUST_PROXYand are per API instance. - At least one platform admin watches the queue (see Who is staff): tickets with no organization, or of a reporter who left it, reach the platform only.
- The proxy allows request bodies of at least 26 MB on
/api/support/tickets*,/api/helpdesk/tickets/*/messagesand/api/organization/helpdesk/tickets/*/messages, or the limits are lowered to match.