Support (Zendesk) — how it works¶
Everything about customer support in Toborverse: what the customer sees, how requests reach Zendesk, how replies and files come back, what is stored in Postgres and S3, the Zendesk setup the code relies on, configuration, limits and security.
One rule explains most of the design: Zendesk is the source of truth. The platform
writes to Zendesk (new requests, customer replies) and then copies the ticket back.
Nothing is written to support.* tables or the support S3 bucket except by the sync,
which always re-reads the ticket from the Zendesk API. Running the sync again, twice, or
out of order is harmless.
1. Big picture¶
Customer (browser) Backend (Express) Zendesk
─────────────────── ───────────────── ───────
Support page ── POST /api/support/requests ──► upload files ──► /uploads.json
(form + files) create ticket ─► /tickets.json (tag toborverse_app)
syncTicket() in background
My requests ── GET /api/support/tickets ────► read support.tickets
Conversation ── GET /api/support/tickets/:id ► syncTicket() then read messages
Reply box ── POST /api/support/tickets/:id/replies
(text + files) upload files ──► /uploads.json
add comment ───► PUT /tickets/:id.json (as requester)
syncTicket()
agent replies in Zendesk
│
POST /api/support/zendesk-webhook ◄── webhook (HMAC signed) ◄──────┘
syncTicket() in background
every 60 s: syncChangedTickets() ── search tickets tagged toborverse_app
syncTicket(): Zendesk API ──► support.tickets / support.messages (Postgres)
──► files: Zendesk ──► S3 bucket ──► support.attachments
──► malware files: support.unusual_attachments (no file)
──► clears the "Handled By" field in Zendesk (if enabled)
2. Files¶
| Area | File | What it does |
|---|---|---|
| Backend | backend/src/routes/support.js | POST /api/support/requests — new request from the Support page |
| Backend | backend/src/routes/supportHistory.js | My requests list, conversation, customer replies (with files), Zendesk webhook |
| Backend | backend/src/services/zendeskService.js | Zendesk API client (OAuth or API token), file upload, ticket creation |
| Backend | backend/src/services/supportSync.js | Copies a ticket from Zendesk into the database and S3; background sync; Handled By |
| Backend | backend/src/services/supportStore.js | All SQL for the support.* tables |
| Backend | backend/src/services/supportAttachments.js | S3 paths, copying files from Zendesk to S3, file types, download links |
| Backend | backend/src/app.js | Mounts the webhook (before the JSON parser) and the authenticated /api/support routers |
| Backend | backend/server.js | Starts the background sync after the server starts listening |
| Frontend | frontend/src/pages/SupportPage.jsx | /support: "New request" form and "My requests" tab |
| Frontend | frontend/src/pages/SupportRequests.jsx | Request list, conversation view, reply box with attachments |
| Frontend | frontend/src/service/dataEpisodeService.js | createSupportRequestApi, getSupportTicketsApi, getSupportTicketApi, replySupportTicketApi |
| Frontend | frontend/src/apiList.js | SupportRequestCreate, SupportTickets URLs |
| Database | database/schemas/support.sql | Full current schema of the support schema |
| Infra | infra/terraform/environments/dev/variables.tf |
extra_bucket_arns includes toborverse-dev-support-attachments |
3. Customer experience (frontend)¶
The Support item in the sidebar opens /support, which has two tabs.
3.1 New request¶
| Field | Required | Notes |
|---|---|---|
| Your email address | yes | Prefilled from the logged-in user (/users/me). Becomes the Zendesk requester. |
| Name | no | Prefilled from firstName lastName (also accepts first_name/last_name). |
| Robot | yes | Dropdown of the org's robots plus "Other". Sent as a readable label name (model). |
| Robot Serial Number | no | Filled automatically from the selected robot's robot_serial_no; editable. |
| Subject | yes | |
| Description | yes | Small rich-text editor (bold, italic, code, link, lists, headings). Sent as HTML. |
| Attachments | no | Drag and drop or click. Max 20 MB per file, 10 files. Duplicates (same name + size) are ignored. |
On success: toast "Your request #N has been submitted…", the form is cleared and the view switches to My requests. The form is hidden, not unmounted, when switching tabs, so a half-written request survives.
3.2 My requests¶
- List of the user's requests:
#ticket subject, robot, last update, message count, status. - Status shown to customers:
| Zendesk status | Shown as |
|---|---|
| new | Received |
| open | In progress |
| pending | Awaiting your reply |
| hold | On hold |
| solved | Solved |
| closed | Closed |
- Conversation view: robot, serial number, "Handled by" (shows the Zendesk
assignee name, or "Waiting for an agent"), then every public message in order.
Customer messages show the requester's name; agent messages show the agent's
display name from
support.agents(never their code), or "Toborverse Support". Each message lists its files as links (see 6.4). - Reply box: text plus Attach files (same limits: 10 files, 20 MB each). A reply can be text only, files only, or both. Closed requests show "This request is closed. Submit a new request if you still need help." instead of the box.
- Refresh buttons reload the list / conversation. Opening a conversation always refreshes it from Zendesk first.
- Email links:
/support?request=<zendesk ticket number>opens My requests and then that conversation. If it isn't found: "We couldn't find request #N. A new request can take a minute to appear." The?request=is then removed from the URL.
4. Backend API¶
All /api/support/* routes except the webhook require the normal user login
(authenticateUser → req.auth with user_id, org_id, email) and go through the
app's rate limiter.
4.1 POST /api/support/requests — new request¶
Multipart form: email, name, robot, serial_number, subject, description, attachments[].
- Files are read into memory by multer (20 MB / 10 files; errors → 400 with a readable message).
- Validation (400 on failure): valid email; robot, subject and description (with HTML tags removed) not empty; description at most 50,000 characters.
zendeskService.createSupportTicket:- each file →
POST /uploads.json?filename=…→ upload token; POST /tickets.jsonwithsubject,comment.html_body = description,comment.uploads = [tokens],requester {email, name},tags: ['toborverse_app'], custom fields Robot and Serial (if their IDs are configured),ticket_form_id(if configured).- Responds
{ success, data: { ticket_id } }straight away. - In the background:
syncTicket(ticket_id, { created_by: user_id, org_id }). This is the only path that tells the database which org and user the ticket belongs to.
Zendesk failure → 502 "Could not submit your request to support…".
4.2 GET /api/support/tickets — My requests¶
Runs inside withOrg(org_id). Returns tickets that are not deleted and were either
created by this user or have this user's email as requester, newest Zendesk update first.
4.3 GET /api/support/tickets/:id — conversation¶
- Loads the ticket (
:idis our UUID, not the Zendesk number);canViewmust pass (creator, or requester email matches) → otherwise 404. - Calls
syncTicketto refresh from Zendesk. If the ticket was deleted in Zendesk → 404. If Zendesk is unreachable, the stored copy is shown. - Returns ticket fields +
messages(public only), each withattachments: [{id, file_name, url}].
4.4 POST /api/support/tickets/:id/replies — customer reply¶
Accepts JSON { message } or multipart message + attachments[].
canViewcheck → 404. Need a message or at least one file → 400. Message at most 10,000 characters → 400. Ticket closed → 409.- Each file →
/uploads.json→ token. PUT /tickets/:zendesk_id.jsonwith a public comment authored as the requester (author_id = requester's Zendesk id), body as plain text (so<>stay as typed), plusuploads. A files-only reply gets the bodyAttached: <file names>because Zendesk requires a body.syncTicketand returns the refreshed conversation.
Zendesk 404 → ticket marked deleted, 404 "This request no longer exists". Other errors → 502.
4.5 POST /api/support/zendesk-webhook — Zendesk webhook¶
- Mounted before
express.json()and without user login; reads the raw body (≤ 1 MB). - Returns 503 if
ZENDESK_WEBHOOK_SECRETisn't set; 401 if the signature is wrong. - Signature check:
base64(HMAC-SHA256(secret, timestamp + raw body))compared (timing-safe) with headerx-zendesk-webhook-signature; timestamp fromx-zendesk-webhook-signature-timestamp. - Body must be JSON with a positive integer
ticket_id→ otherwise 400. - Answers 200 immediately, then runs
syncTicket(ticket_id)in the background and logs[Support webhook] Ticket N synced (k new messages)or… failed: ….
5. The sync (supportSync.js)¶
5.1 syncTicket(zendeskTicketId, local) step by step¶
GET /tickets/:id.json?include=users.- 404 → ticket deleted in Zendesk →
deleted_atset in the database, returnsnull. - Ticket without the
toborverse_apptag → ignored, returnsnull. GET /tickets/:id/comments.json?include=users(all pages).GET /tickets/:id/audits.json(all pages) → which Handled By code was entered with which reply (5.3).- Builds the message list from public comments only (internal notes are never stored) and decides who wrote each one (5.2).
upsertTicket(byzendesk_ticket_id). Org is taken from: the existing row → elselocal.org_id(new request from the platform) → else the requester's email if it belongs to exactly one org (users+organization_members). Otherwise the ticket is skipped ([Support store] Ticket N skipped: no org for <email>) and the sync stops here.- For each message:
upsertMessage(byticket_id + zendesk_comment_id, updates the ticket'smessage_countandlast_message_at), thencopyAttachments(5.4). - If the Handled By field has a value →
clearHandledBy(5.3).
5.2 Who wrote a message (author_type)¶
- The reply has a Handled By code entered in the same update →
agent(customers can't see or fill that field). - Author is the ticket's requester →
customer(even if that Zendesk user is an agent/admin). - Author's Zendesk role is
end-user→customer; any other role →agent. - Unknown author →
system.
5.3 Handled By (agent codes)¶
Agents type their personal code (e.g. CN01) into the agent-only Handled By field when
they reply ("code every reply").
- A reply is credited to the code changed in the same Zendesk update (audit) as the reply. Changes made by triggers are ignored. Codes are trimmed and upper-cased.
messages.agent_code/agent_id= that code and the matchingsupport.agentsrow.tickets.handled_by_code/handled_by_id= code of the latest coded agent reply, else the field's current value.- A code that isn't in
support.agentsis still stored as typed; the name just stays empty. - Clearing: Zendesk triggers can't empty a custom field, so when
ZENDESK_CLEAR_HANDLED_BY=truethe sync sendsPUT /tickets/:id.jsonwithcustom_fields: [{ id: <Handled By>, value: null }]after reading it. No comment is added, so no email goes out. The next reply then needs its own code, even from the same agent. In the ticket's Zendesk events this shows as an update by the API user setting Handled By to empty. Failures are logged asCould not clear Handled By on ticket N: …. - It runs at the end of
syncTicket, so it happens only when the ticket was synced successfully and has an org (see 5.1 step 5).
5.4 Attachments (copyAttachments)¶
For every file on every public message (customer or agent), in this order:
Zendesk malware_scan_result |
What happens |
|---|---|
malware_found |
Never copied. A row in support.unusual_attachments (once per Zendesk file; user_id = ticket creator if the customer sent it, else NULL). Logged as a warning. Recorded even when the bucket isn't configured. |
| bucket not configured | Nothing else happens (messages show no files). |
| file already stored | Skipped (matched by Zendesk attachment id from the S3 path). |
not_scanned, failed_to_scan, missing |
Not copied yet; warning logged; tried again on a later sync of the ticket. |
malware_not_found |
Downloaded from Zendesk (with the API auth header), uploaded to S3, row added to support.attachments. |
A failed copy is logged (Attachment N on ticket M failed: …) and retried on a later sync.
5.5 Background sync¶
Started by server.js (only if Zendesk is configured). Every SUPPORT_SYNC_INTERVAL_SEC
seconds (default 60), skipped if the previous run is still going:
- Search Zendesk
type:ticket tags:toborverse_app(all pages). - For each result whose
updated_atdiffers from the storedzendesk_updated_at(or that isn't stored) →syncTicket. - Deletions: every stored active ticket missing from the search is checked directly; a 404 marks it deleted. (The search can lag behind new tickets, so it isn't trusted alone.)
- Logs
[Support sync] Checked X, synced Y, deleted Zwhen something changed.
It is the safety net for missed webhooks; with a working webhook, changes arrive within seconds.
6. Files in S3¶
6.1 Bucket and credentials¶
- Bucket:
SUPPORT_S3_BUCKET_NAME(dev:toborverse-dev-support-attachments), private, separate from the data bucket. Region:SUPPORT_S3_REGION, elseAWS_REGION. - Uses the app's AWS keys (
config.aws). That IAM identity needss3:PutObjectands3:GetObjecton the bucket. Without the bucket variable, file copying is off.
6.2 Path¶
s3://<bucket>/<org_id>/<ticket uuid>/<zendesk attachment id>/<YYYY-MM-DD>/<file name>
- One object per Zendesk attachment, so a re-sync builds the same path and doesn't copy twice.
- The date is the day the reply was posted in Zendesk (UTC);
undatedif unknown. - File name: invisible / direction-changing Unicode characters removed (so
invoice\u202Egpj.execan't pose asinvoiceexe.jpg), control characters →_,/and\→_, empty →file.
6.3 File type (file_type)¶
From the extension; if unknown, from Zendesk's content type (video/mp4 → video).
| Type | Extensions |
|---|---|
| image | jpg jpeg png gif bmp webp tif tiff svg heic heif ico avif |
| audio | mp3 wav m4a aac ogg oga flac wma opus aiff |
| video | mp4 mov avi mkv webm m4v wmv flv 3gp mpeg mpg |
| text | txt csv log md |
| unknown | everything else |
The list in supportAttachments.js and in migration 123 must be kept in step.
6.4 Download links¶
- The S3 path never leaves the server. Each time a conversation is loaded, every file gets a presigned link valid for 15 minutes.
- Opens in the browser only for: jpg jpeg png gif webp bmp avif, mp4 m4v webm mov, mp3 wav m4a aac ogg oga flac opus, txt csv log md (as plain text), pdf. The content type served comes from the extension, never from what Zendesk reported.
- Everything else (HTML, SVG, Office files, archives, …) is downloaded
(
attachment,application/octet-stream), so an uploaded file can't run as a web page.
7. Database (support schema)¶
Full SQL: database/schemas/support.sql.
7.1 Tables¶
support.agents — support staff and their codes. Filled by hand; the app never writes it.
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | |
| code | VARCHAR UNIQUE | What agents type into Handled By, e.g. CN01 (stored upper-case) |
| full_name | VARCHAR | |
| display_name | VARCHAR | Shown to customers on agent replies (falls back to full_name) |
| is_active | BOOLEAN | Only checked by findAgentByCode; the stored handled_by_id / agent_id match the code regardless |
| created_at | TIMESTAMPTZ |
INSERT INTO support.agents (id, code, full_name, display_name)
VALUES (gen_random_uuid(), 'CN01', 'Full Name', 'First name');
support.tickets — one row per Zendesk ticket tagged toborverse_app.
| Column | Notes |
|---|---|
| id | UUID PK (UUIDv7), used in platform URLs/API |
| org_id | FK organizations, NOT NULL — which org owns it (never overwritten once set) |
| created_by | FK users — platform user who filed it (kept once set) |
| robot_id | FK robots — currently not filled by the code |
| zendesk_ticket_id | UNIQUE — Zendesk ticket number |
| requester_email / requester_name / zendesk_requester_id | From the Zendesk requester |
| robot_label / serial_number | From the Zendesk custom fields |
| subject / status / priority | From Zendesk |
| assignee_name | Zendesk assignee (shown as "Handled by" to customers) |
| handled_by_code / handled_by_id | Agent code of the latest coded reply / FK agents |
| message_count / last_message_at | Public messages, kept up to date by upsertMessage |
| zendesk_updated_at | Zendesk updated_at; the background sync compares this |
| last_synced_at | Last successful sync |
| deleted_at | Set when the ticket is deleted in Zendesk (hidden from customers) |
| created_at / updated_at | created_at = Zendesk creation time |
Indexes: org_id, created_by, lower(requester_email).
support.messages — public Zendesk comments.
| Column | Notes |
|---|---|
| id | UUID PK; (id, org_id) also UNIQUE (for the attachments FK) |
| ticket_id | FK tickets ON DELETE CASCADE |
| org_id | FK organizations |
| zendesk_comment_id | UNIQUE with ticket_id |
| author_type | customer / agent / system |
| agent_code / agent_id | Only for agent replies with a code |
| body_text | Plain text (HTML entities decoded) — the only body the app shows |
| body_html | Zendesk HTML as written — untrusted, never rendered |
| is_public | Always true today (internal notes aren't stored) |
| created_at | Zendesk comment time |
support.attachments — files copied to S3.
| Column | Notes |
|---|---|
| id | UUID PK |
| message_id + org_id | FK → messages(id, org_id) ON DELETE CASCADE (a file can't point at another org's message) |
| file_name | Cleaned name shown in the chat |
| s3_path | UNIQUE, must match ^s3://[^/]+/.+ |
| size_bytes | BIGINT, as reported by Zendesk (NULL for files copied before migration 122) |
| file_type | image / audio / video / text / unknown (CHECK) |
| created_at |
support.unusual_attachments — files Zendesk flagged as malware. No file is stored.
| Column | Notes |
|---|---|
| id | UUID PK |
| ticket_id | FK tickets ON DELETE CASCADE |
| org_id | FK organizations |
| user_id | FK users — the customer who sent it; NULL if an agent sent it |
| zendesk_attachment_id | UNIQUE — one row per Zendesk file |
| scan_result | e.g. malware_found |
| detected_at |
7.2 Relationships¶
agents ◄── tickets.handled_by_id
agents ◄── messages.agent_id
tickets ◄── messages.ticket_id (cascade)
tickets ◄── unusual_attachments.ticket_id (cascade)
messages(id, org_id) ◄── attachments(message_id, org_id) (cascade)
organizations / users / robots ◄── tickets, messages, attachments, unusual_attachments
7.3 Row-level security and database users¶
tickets,messages,attachmentsandunusual_attachmentshave RLS enabled with a policyorg_id::text = current_setting('app.current_org', true).- Customer routes run inside
withOrg(org_id): a transaction withset_config('app.current_org', <org>, true). - The sync and webhook work across all orgs and use
syncPool, which connects asSUPPORT_SYNC_DB_USER(meant to be aBYPASSRLSrole). If that variable isn't set they use the main DB user, with the startup warningSUPPORT_SYNC_DB_USER not set: sync and webhook use the main DB user. - Caveat: RLS is
ENABLEd, notFORCEd, and the tables are owned by the app's DB user (toborverse), so for that user the policies don't apply. What a customer can see is in practice decided by the code (canView,listTicketsForUser). The comment at the top ofsupportStore.jssays FORCE; the schema doesn't.
8. Zendesk setup the code relies on¶
| Item | Where in Zendesk | Needed for |
|---|---|---|
Tag toborverse_app |
added automatically on create | Only tagged tickets are synced; removing the tag hides the ticket from the sync |
| API access | Admin Center → Apps and integrations → APIs | OAuth client (client credentials, scope read write) or API token; the account must be an agent/admin that can edit tickets |
| Ticket form | Admin Center → Objects and rules → Tickets → Forms | Optional ZENDESK_FORM_ID for new requests |
| Custom field Robot (text) | … → Tickets → Fields | ZENDESK_ROBOT_FIELD_ID |
| Custom field Serial number (text) | … → Tickets → Fields | ZENDESK_SERIAL_FIELD_ID |
| Custom field Handled By (agent-only) | … → Tickets → Fields | ZENDESK_HANDLED_BY_FIELD_ID; agents enter their code with each reply |
| Webhook | Admin Center → Apps and integrations → Webhooks | URL https://<api host>/api/support/zendesk-webhook, POST, JSON body containing "ticket_id": "{{ticket.id}}", signing secret = ZENDESK_WEBHOOK_SECRET |
| Trigger calling the webhook | Objects and rules → Business rules → Triggers | On ticket created/updated with tag toborverse_app, notify the webhook. Without it, changes arrive only through the 60-second background sync |
| Requester notification trigger | Triggers (e.g. "Notify requester of comment update – Toborverse") | Emails the customer on agent replies. A link to https://<app host>/support?request={{ticket.id}} in the email opens that conversation on the platform |
| Malware scanning | built into Zendesk | Provides malware_scan_result on each attachment |
Notes: * No trigger or automation should set Handled By, or it will be refilled after the sync clears it. * Closed tickets can't be edited in Zendesk, through the API either: our reply API refuses them with 409, and clearing Handled By on them fails (logged). * The webhook check uses the signing secret shown on the webhook's page in Zendesk.
9. Configuration (backend/.env)¶
Names only; values live in the server's .env (never commit them).
| Variable | Meaning |
|---|---|
ZENDESK_SUBDOMAIN |
<sub>.zendesk.com |
ZENDESK_CLIENT_ID, ZENDESK_CLIENT_SECRET |
OAuth client credentials (preferred) |
ZENDESK_OAUTH_SCOPE |
Default read write; must allow writes |
ZENDESK_EMAIL, ZENDESK_API_TOKEN |
Fallback auth (email/token:api_token) when OAuth isn't set |
ZENDESK_FORM_ID |
Optional ticket form for new requests |
ZENDESK_ROBOT_FIELD_ID, ZENDESK_SERIAL_FIELD_ID |
Custom field IDs |
ZENDESK_HANDLED_BY_FIELD_ID |
Handled By field ID |
ZENDESK_CLEAR_HANDLED_BY |
true (exactly) to clear Handled By after each sync; anything else = off |
ZENDESK_WEBHOOK_SECRET |
Webhook signing secret; webhook answers 503 without it |
SUPPORT_SYNC_INTERVAL_SEC |
Background sync interval, default 60 |
SUPPORT_SYNC_DB_USER, SUPPORT_SYNC_DB_PASSWORD |
DB role for the sync (BYPASSRLS); falls back to the main user |
SUPPORT_S3_BUCKET_NAME |
Support attachments bucket; empty = file copying off |
SUPPORT_S3_REGION |
Defaults to AWS_REGION |
Zendesk is "configured" when ZENDESK_SUBDOMAIN is set plus either OAuth or email+token.
If not, new requests return 500 "Support is not configured on the server" and the
background sync logs that it is off.
OAuth tokens are cached until one minute before they expire; a 401 drops the cache so the next call gets a new token.
10. Limits and validation summary¶
| What | Limit | Where enforced |
|---|---|---|
| File size | 20 MB each | frontend + multer |
| Files per request / reply | 10 | frontend (reply) + multer |
| Description (new request) | 50,000 chars (HTML) | backend |
| Reply text | 10,000 chars | backend |
| Webhook body | 1 MB | backend |
| Reply JSON body | 100 KB | backend |
| Download link lifetime | 15 minutes | S3 presign |
11. Security summary¶
- Webhook: HMAC signature with timing-safe comparison; only the ticket id is taken from the body — everything else is re-read from the Zendesk API.
- Message bodies are shown as plain text (
body_text);body_htmlis never rendered. If it ever is, clean it first (e.g. DOMPurify). - Malware-flagged files are never downloaded or stored; unscanned files wait for a clean scan.
- S3 paths stay on the server; links are short-lived; risky types are forced to download; content type comes from the extension.
- File names are cleaned of direction-changing and control characters.
- Customer replies are sent to Zendesk as plain text, authored as the requester.
- Treat stored message text and file names as untrusted input if they are ever fed to other systems (exports, AI tools, emails).