Skip to content

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, or Helpdesk.Read organization-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 as unit[] and member[]; removing the organization clears both; units and members for organization staff who also hold Members.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 holding Helpdesk.Reply and Helpdesk.UpdateStatus. The first public reply moves OPEN to IN_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_REPLIED and HELPDESK_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.isPlatformAdmin flag) 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.Read held 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.Reply held in unit A and Helpdesk.Read in units A and B answers the tickets of A only. The built-in ORG_ADMIN and UNIT_ADMIN hold the four permissions.
RoutePermission
GET /api/organization/helpdesk/ticketsHelpdesk.Read
GET /api/organization/helpdesk/tickets/:idHelpdesk.ReadOne
POST /api/organization/helpdesk/tickets/:id/messagesHelpdesk.Reply
PATCH /api/organization/helpdesk/tickets/:idHelpdesk.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>.

SetupSettings
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 S3STORAGE_DRIVER=s3, S3_BUCKET, S3_REGION; credentials from the instance role, or S3_ACCESS_KEY_ID + S3_SECRET_ACCESS_KEY
MinIOas S3, plus S3_ENDPOINT=http://localhost:9000 and S3_FORCE_PATH_STYLE=true
Cloudflare R2as 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):

KeyDefaultMeaning
STORAGE_DRIVERlocallocal or s3
STORAGE_DIRtmp/storagelocal only; required with NODE_ENV=production, absolute path on a persistent volume
S3_BUCKET, S3_REGIONrequired with STORAGE_DRIVER=s3 (auto for R2)
S3_ENDPOINTAWSabsolute http(s) URL (MinIO, R2)
S3_FORCE_PATH_STYLEfalsetrue for MinIO
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEYboth 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_DIR and 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 inside STORAGE_DIR could swap a directory for a symlink. Keep STORAGE_DIR writable 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-secret

Create 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, APNG acTL/fcTL/fdAT) and colour chunks (gAMA, cHRM, sRGB, iCCP, sBIT, bKGD, hIST, pHYs, cICP, mDCV, cLLI) survive. Text chunks, eXIf, tIME and every unknown or private chunk are dropped;
    • WebP: EXIF and XMP ;
    • 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 are Content-Disposition: attachment, X-Content-Type-Options: nosniff and Cache-Control: private, no-store. There are no public or presigned URLs.

5. Failure modes

What breaksWhat 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 uploadThe request deletes the objects it stored (logged at warn with the keys) and answers the original error.
The process dies between upload and commitAn orphan object with no row: never served (serving needs a row). See the sweep below.
A row exists but its object is missing404, logged at error with the key; the client shows “Image unavailable”.
More than 100 staff usersNotifications reach the 100 oldest staff accounts only (platform admins and the organization’s staff together).
Several API instancesEach 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=s3 with a private bucket, or STORAGE_DIR on a persistent volume (single host only; several instances need s3).
  • Backups include the bucket or the directory, not only the database.
  • Existing production .env files gained STORAGE_DIR (or s3) 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_PROXY and 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/*/messages and /api/organization/helpdesk/tickets/*/messages, or the limits are lowered to match.