Forum and tickets — support as a thread
The forum at /forum: categories, threads, posts, and the ticket system that is built out of exactly those three things. This is the reference for public/forum.h
The forum at /forum: categories, threads, posts, and the ticket system that is built out of exactly those three things. This is the reference for public/forum.html + public/js/forum.js, public/thread.html + public/js/thread.js, public/js/forum-common.js, lib/forum.js and routes/forum.js.
What the forum is
Categories hold threads; threads hold posts. And a ticket is not a fourth thing.
A ticket IS a thread. There is no second table, no second URL and no second post style. What makes a thread a ticket is its category:
forum_categories.kind = 'discussion' -> a thread, no status, ever
forum_categories.kind = 'support' -> a ticket: forum_threads.status is set
The reasoning, as lib/forum.js states it: a ticket is therefore something you can answer, subscribe to and search like any other thread, and "is this a ticket" is answered by the category rather than by a flag somebody can set on one row and forget on the next.
Creating a thread in a support category, inside one transaction:
- gets
status = 'open'; - writes a
forum_ticket_eventsrow with the noteTicket opened.; - inserts a
forum_subscriptionsrow for the asker — "a support ticket nobody is told about is a support ticket that quietly disappears".
Moving a thread into a support category later gives it its status too, with the trail note Moved into support., because a thread that lands in the queue without a status is a question with no owner, and that is the one thing the queue cannot sort. Moving a ticket out of support drops its status note ("a note that says 'waiting on firmware' is not true of a conversation") and writes no trail row: the trail is the ticket's history, and forum_ticket_events.to_status is NOT NULL.
The three tables are forum_categories, forum_threads, forum_posts, with forum_ticket_events and forum_subscriptions hanging off the thread.
Categories
Five are seeded by DEFAULT_CATEGORIES in lib/db.js, inserted inside a transaction with ON CONFLICT(slug) DO NOTHING, so a fresh install has a usable forum on the first request and an install that already has categories keeps exactly the ones it has.
| slug | name | kind | sort | description |
|---|---|---|---|---|
general |
General | discussion | 10 | The Z3 itself, this hub, and anything that does not fit anywhere else. |
profiles |
Profiles & bindings | discussion | 20 | Share a .spcf, ask about DPI steps, deep-click, tilt and OLED setups. |
help |
Support | support | 30 | Stuck on something? Open a ticket here and a moderator picks it up and keeps you posted. |
bugs |
Bugs | support | 40 | Something on the site or in the mouse behaves wrongly. Tickets here carry a status. |
off-topic |
Off-topic | discussion | 50 | Everything else. |
A category added later by a moderator can be either kind.
| Limit | Value |
|---|---|
MAX_CATEGORY_NAME |
60 (minimum 2 characters after cleaning) |
MAX_CATEGORY_DESCRIPTION |
300 |
| slug | unique; slugify() produces it, max 40 chars, non-empty required |
sort |
integer; when omitted, MAX(sort) + 10 |
Categories are created, renamed and deleted by moderators: POST /api/forum/categories, PATCH /api/forum/categories/:id, DELETE /api/forum/categories/:id. The slug is deliberately not editable — it is the URL, and a link that has been shared would 404.
A category with threads cannot be deleted out from under them. deleteCategory() answers 409:
Move or delete the N thread(s) in there first — deleting a category does not take its threads with it.
An unknown kind on create or update falls back to discussion, never to a third value.
The four ticket states
STATUSES in lib/forum.js, in the order a ticket actually moves:
| value | label | hint (verbatim) |
|---|---|---|
open |
Open | Nobody is working on it yet. |
in_progress |
In progress | A moderator is on it. |
on_hold |
On hold | Waiting on something — usually the person who opened it. |
closed |
Closed | Done, or not going to happen. |
closed is not the end of the world: a ticket can be reopened, and the trail records it. STATUS_SHORT is a second map used for the compact badge.
Every status change writes a row to forum_ticket_events — actor, timestamp, from_status, to_status, note — inside the same transaction, before the thread is updated. If the trail write fails, the status does not move. Then routes/forum.js notifies the watchers and the asker.
The trail is rendered on the thread page. The source says why it matters:
The trail is the ticket's history… without it a support desk is just a status field that gets discussed in the thread.
Two refusals are worth quoting because they are the shape of every error in the module:
400 That is a discussion, not a ticket — it has no status.
400 Status must be one of: open, in_progress, on_hold, closed.
400 This ticket is already <state>.
The last one is refused only when no note was given — a note is a reason to re-save the same state, because it is new information. Notes are cleaned with cleanText(note, 300, '').
Extending the statuses
Adding a status means changing three places, because test/forum-pages.test.js ("the ticket states are the same on both sides of the wire") fails when any of them is missing:
STATUSESinlib/forum.js— the data, and the source of truth;STATUSESinpublic/js/thread.js— the four buttons in the status strip;.st-<value>inpublic/css/forum.css— the colour.
The test also checks public/js/forum.js, so the ticket queue filter needs the value as well. The queue filter list is a hardcoded five entries with the labels Needs work, Open, In progress, On hold, Closed.
Current colours:
.fr-badge.st-open { color: var(--blue); … }
.fr-badge.st-in_progress{ color: var(--lime-hi); … }
.fr-badge.st-on_hold { color: var(--orange); … }
.fr-badge.st-closed { color: var(--text-3); background: var(--surface-2); }
A known gap. thread.js builds each button's title from s.hint || s.label, but its own local STATUSES list has no hint property. The tooltips are therefore just the labels. The hint text exists on the server and is not delivered to the client. See Known gaps.
The forum index
public/forum.html + public/js/forum.js. Everything is rendered in one pass from two requests.
Tool strip
| Element | Id | Behaviour |
|---|---|---|
| Search | #forum-search |
placeholder "Search threads and posts…" |
| Filters | #forum-filters |
built by JS |
| Sort | #forum-sort |
five options |
| → Updates | ghost link | ./updates |
| New thread | #forum-new |
title "Start a thread" |
| Ticket queue | #forum-queue |
hidden until moderator |
The sort options are activity / Latest activity, newest / Newest, oldest / Oldest, replies / Most replies, views / Most viewed. Every one of them sorts pinned threads first.
The page
The eyebrow is "Community forum · and the support desk", the h1 is Ask, answer, or open a ticket., and #forum-stats is a <dl> of Threads, Posts, Open tickets, Categories — note that Open tickets counts everything with a status that is not closed.
#forum-body renders the thread list. The sidebar is three cards:
| Id | Heading | Sub | Content |
|---|---|---|---|
#side-tickets |
Open tickets | "Everything not yet closed, oldest first." | data.latest |
#side-activity |
Latest activity | — | the latest 8 posts, linking to /forum/t/:id#p-<id>, with the first 90 characters of the body |
#side-updates |
Latest updates | "Website or mouse — whichever you care about." | the 3 newest, linking to /updates#u-<id> |
Two honest caveats about that first card. public/forum.html gives the side-tickets id to both its <h2> and its <ul> — the second one is what forum.js writes into, so it is a duplicate id, not a second list. And the data it is filled with is data.latest, which is threadList({ sort: 'activity', limit: 8 }) over every category, not a status IS NOT NULL filter, ordered by most recent activity. The heading and the sub-line both promise something narrower than what is rendered.
Filters and grouping
Filters are Everything, Tickets, then one button per category with its thread count. The active one carries aria-pressed.
- A specific category filter renders one
categoryBlock. Ticketsrenders every support category, in configured order, so a ticket keeps its address when it is read.- No filter renders all categories in
sortorder. - An empty category renders
Nothing here yet. - An empty result renders
.up-emptywith search-specific copy (No thread matches "…". Try fewer words, or start the thread yourself.) or category-specific copy (This category has no threads yet. Start one.).
A categoryBlock is a <section class="fr-cat" id="cat-<slug>"> with the category name, a tickets tag on support categories, N threads · N posts, the description, and the rows.
A thread row is an anchor: the avatar, the title, badges (Pinned, the status badge, Locked), a meta line categoryName · username · watching, an optional excerpt, and N replies · <relTime>.
Loading, and the missing pager
Two calls:
GET /api/forum
GET /api/forum/threads?sort=<sort>&limit=60[&q=<term>][&category=<slug>|&status=any_ticket]
The first returns stats, categories, latest (8), latestPosts (8), updates (3), updateCounts, isModerator and signedIn.
Search is debounced 260 ms and searches thread titles and any post body — the thread list query itself, not the search endpoint.
Documented gap: there is no pager on the forum index. The page hard-codes limit: 60 and never sends an offset, even though GET /api/forum/threads accepts offset and returns total. A forum with more than 60 threads shows only the newest 60 with nothing saying so.
Starting a thread
#new-thread-overlay is a modal--form modal--wide with:
| Element | Id | Content |
|---|---|---|
| heading | #nt-heading |
"New thread" |
| hint | #nt-hint |
markdown help, plus "Your draft is kept until you post it." |
| category cards | #nt-cats |
one label.fr-cat-card per category: name, a ticket tag on support categories, description |
| title | #nt-title |
maxlength="120", required, placeholder "One line that says what you want to know" |
| title note | #nt-title-note |
rewritten by category kind |
| editor mount | #nt-editor |
SPMarkdown.create, blocks: false, draftKey: 'sp:forum:draft', 10 rows |
| foot | #nt-foot |
rewritten by category kind |
| error | #nt-error |
role="alert" |
| submit | #nt-submit |
"Post thread", or "Open ticket" |
describeCategory() rewrites three strings by the chosen category's kind:
| Element | support | discussion |
|---|---|---|
#nt-title-note |
"A ticket is followed to a conclusion: somebody picks it up and marks it in progress, on hold or closed." | "Say the thing, not "help" — this is what shows in the list and in search." |
#nt-foot |
"Starts as Open. You and every reply get told when it moves." | "Yours to edit while you are at it, and to delete by a moderator." |
#nt-submit |
Open ticket |
Post thread |
Signed out, New thread navigates to the Discord login and returns to the same address. A restored draft toasts Your unfinished draft was restored.; otherwise the title gets focus.
An empty body is refused client-side:
Write something first — a title and an empty post helps nobody.
On success the draft is cleared and the browser navigates to the new thread.
Server limits (lib/forum.js): MAX_TITLE 120, MIN_TITLE 4, MAX_BODY 8000, MIN_BODY 2. The two refusals are The title needs at least 4 characters. and Write something first.
A thread page
public/thread.html + public/js/thread.js. The page is identical for a conversation and for a ticket, because a ticket is a thread.
Tool strip
| Element | Id | Behaviour |
|---|---|---|
| ← Forum | #th-back |
rewritten to /forum?cat=<category slug> |
| crumb | #th-crumb |
CategoryName · N replies |
| status strip | #th-status-bar |
hidden for a discussion |
| separator | #th-sep |
hidden for a discussion |
| moderator bar | #th-mod-bar |
hidden for a reader |
| Watch | #th-subscribe |
aria-pressed, title "Get a notification when somebody replies" |
The status controls live in the tool strip rather than in the ticket panel on purpose: a moderator working a queue changes the state of a dozen tickets, and the control has to be in the same place on every one of them.
Rendered layout
#thread-body renders, in order: a crumb Forum › CategoryName (plus › ticket), the title with the Pinned / status / Locked badges, a meta line (by <author link> · <date> · N replies · N views, singular and plural handled), the ticket panel when there is one, the pager, the posts, and the composer.
Posts
Each post is an article.fr-post with id="p-<id>" (and is-first on the first), carrying:
- the author's avatar and a link to their member page at
/users/<id>, or the plain text "Deleted user"; - a
stafftag on staff posts — decided from the author's Discord id, not the reader's, so a post looks the same to everybody who reads it; relTimeplus· editedwhenedited_atis set;- the server-rendered HTML body injected unescaped.
That last point is the only unescaped insertion in the application, and it is safe because lib/markdown.js runs markdown-it with html: false (so a raw tag is rendered as the characters typed) and every URL goes through a scheme allowlist. test/ui-shape.test.js checks that exactly one place does this and that it says why. A deleted post renders a dimmed This post was deleted. and the row is marked is-deleted.
The action row offers:
- Edit — on a live post, for its author or any moderator;
- Delete — moderator only, danger-styled;
- Report — any signed-in reader.
The ticket panel and trail
section.fr-ticket, aria-labelledby="ticket-h":
h2#ticket-hreadingTicket;- the current status badge;
set <relTime> by <username>fromstatusChangedAt/statusChangedBy;- the optional
statusNoteinp.fr-ticket-note; ul.fr-trail, oneliperforum_ticket_eventsrow: a status badge, the actor or the system, the time, and— notewhen a note is present.
An empty trail renders nothing at all — the trail() helper returns '' rather than an empty list.
Watching, replying, editing, removing, reporting
Watch. #th-subscribe toggles POST / DELETE /api/forum/threads/:id/subscribe. It is hidden for a signed-out reader. Replying also subscribes you, which is why the reply hint mentions it.
The composer shows one of three things:
| State | Copy |
|---|---|
| signed out | Sign in with Discord to reply. plus a Log in button |
| locked, reader | This thread is locked, so nobody can reply to it. |
| otherwise | a reply box whose hint is Ctrl + Enter posts. Replying adds you to the watchers of this thread. |
The reply editor is the same SPMarkdown instance as the new-thread form, blocks: false, draftKey: 'sp:forum:reply:<id>', 6 rows. It is rebuilt on every render rather than kept, with the half-written value carried across. After posting, the page reloads the last window rather than pushing onto whatever is on screen — otherwise a reader who paged back and then replied would find their own reply wedged between two others with the counts around it wrong.
Editing is in place, replacing the body with a full markdown editor (not a bare textarea, so somebody fixing a typo does not lose code blocks), with Save and Cancel. Save is POST /api/forum/posts/:id/edit and toasts Post edited.
Removal is confirm('Remove this post? A moderator can restore the thread around it, but the text is gone.'), then DELETE /api/forum/posts/:id, which is requireModerator. It is a tombstone, not a hard delete: the body is replaced with (deleted), the row keeps its place so replies keep their order, and the client toasts Post removed.
Two extra rules on the server: a non-moderator is refused with Only moderators can remove a post. You can still edit yours., and removing the first post is refused with That is the first post. Delete the thread instead — an admin can do that. — a question cannot be soft-deleted into a thread with no question.
Reporting prompts What is wrong with this post? and posts to /api/reports with targetType: 'post', targetId, reason; the client toasts Reported. A moderator will look at it. The server caps the reason at 500 characters, allows one open report per user per target (409 You already reported this.), and — because the person who can remove a post is a moderator, not an admin — a reported post goes to notifyStaff, not notifyAdmins.
Two documented gaps:
- A report can only be filed from the thread page, and it can only be triaged in the admin panel:
GET /api/reportsandPOST /api/reports/:id/statusare bothrequireAdmin. There is no moderator control anywhere on a thread to resolve a report about it. - There is no route to edit a thread title. The new-thread foot text says "Yours to edit while you are at it", but only posts can be edited.
POST /api/forum/threadsand the post routes are the whole write surface.
Pagination
A window, not silent truncation. POST_PAGE is 50 in routes/forum.js and the server caps limit at 200 (Math.min(Number(query.limit) || POST_PAGE, 200)); lib/forum.js independently caps at MAX_LIMIT 200 with DEFAULT_LIMIT 50.
The pager renders above the posts:
| Button | Shown when | Label | Request |
|---|---|---|---|
| Earliest reply | before > 0 |
Earliest reply |
?limit=50&offset=0 |
| N earlier | before > 0 and posts are on screen |
Math.min(50, before) earlier |
?limit=50&offset=(before − 50) |
| N more replies | after > 0 |
after + ' more repl' + (after === 1 ? 'y' : 'ies') |
?limit=50&offset=max(0, postTotal − 50) |
The third is the last window, and the page scrolls to the newest post with block: 'nearest'. Every numeric target scrolls to the first post with block: 'start'.
?around=<postId> centres the window on a post — postsAround() computes how many posts precede it and offsets by half the page. The client uses it automatically when the URL hash is #p-<id> (the shape a notification deep link or a search result has), and then scrolls that post into view with block: 'center'. A post that no longer exists falls back to the beginning rather than to nothing: the thread is still worth reading.
Views are counted only for a signed-in reader (countView returns early when viewerId is null), so an anonymous reload loop and a moderator clicking through their own queue are not the number a thread shows.
Moderation
The status strip is four buttons, one per state, the active one carrying aria-pressed. Each asks:
window.prompt('Why? (optional — shown on the ticket and to everybody watching)', '')
The note is sliced to 300 characters, and a null return (Cancel) aborts the whole thing — a note is optional, so there is no cancel button, and a support system that records the reason only sometimes is worse than one that never asks. On success the page toasts Ticket is now <state>. and the answer carries the refreshed events.
The action bar (#th-mod-bar) is:
| Control | Id | Effect |
|---|---|---|
category <select> |
#th-move |
POST …/move; toasts Moved. |
| Pin / Unpin | #th-pin |
POST …/pin |
| Lock / Unlock | #th-lock |
POST …/lock |
| Delete | #th-delete |
DELETE …/threads/:id, guarded by confirm('Delete this thread and every post in it? This cannot be undone.'), then back to /forum |
Deleting a thread cascades to its posts, its ticket trail and its subscriptions.
What a moderator cannot do. Everything the admin surface does stays behind requireAdmin in routes/admin.js and is deliberately not reachable from the forum router: no profile deletion, no admin panel, no /records. A moderator can also not change a category's slug, and cannot remove the opening post of a thread. See Roles and permissions.
The ticket queue
A dialog on the forum index (#queue-overlay), not a route — it is a work list somebody opens while they are already there, and a route would be a second thing to keep in step with the page.
It is moderator-only: #forum-queue is revealed from isModerator in the /api/forum response, and GET /api/forum/tickets is requireModerator regardless.
Filters, backed by ?status=:
| value | label |
|---|---|
open_ticket |
Needs work |
open |
Open |
in_progress |
In progress |
on_hold |
On hold |
closed |
Closed |
One aria-pressed button at a time. open_ticket is status IS NOT NULL AND status != 'closed' — "everything a ticket can still move through", because a closed ticket is an answer, not a task. The queue is sorted oldest and then filtered to tickets only, and an unknown status falls back to open_ticket rather than to no filter, which would put every discussion in the queue as a piece of work.
Rows carry the title, the status badge and categoryName · relTime. The empty state is Nothing in this state. ?tickets=1 opens the dialog and then strips the query with history.replaceState — the header's "Ticket queue" menu item links here so somebody who has decided to work the queue does not have to find the button first.
Search
GET /api/forum/search?q= cleans q to 80 characters and returns up to 20 threads (by activity) and 20 posts (by recency). Posts are included because the sentence that solved somebody's problem is a post, and it is usually in a thread whose title says nothing about it.
An empty or invalid q is not an error: the route answers 200 with { q: '', threads: [], posts: [] }.
The forum index page does not use this endpoint. It searches titles and first posts through /api/forum/threads?q= instead, so the index results are thread rows and the endpoint's post half is only used elsewhere.
Notifications
| Event | Who is notified | Type |
|---|---|---|
| a reply | every watcher and the thread author, never the actor | ticket_reply in a support category, otherwise forum_reply |
| a status change | every watcher and the asker, never the moderator who moved it | ticket_status |
| a new ticket | staff | new_ticket |
| a published update | staff | new_update |
| a reported post | staff | report |
notifyThreadAudience() removes the author from the watcher list before fanning out, because a support desk that sends two bells for one answer trains people to ignore the bell.
The delivery chain has three parts:
lib/notify.jswrites the row intonotifications, then publishes the same object tolib/events.js.lib/events.jsis an in-process pub/sub:Map<userId, Set<res>>, writingdata: {json}\n\nto every open response for that user.GET /api/notifications/streamis the SSE endpoint (text/event-stream,no-cache,keep-alive,X-Accel-Buffering: no).public/js/notifications.jspolls every 30 s (POLL_MS = 30000) for the unread count and holds anEventSourcethat reconnects after 3 s on error. A toast lasts 10 s (TOAST_MS = 10000).
Limitation: with a single process, an SSE event reaches only the process that produced it. The 30 s poll is what makes a second process still correct for the badge.
See also Admin panel.
Markdown in the forum
The forum uses the posts engine (lib/markdown.js, class prefix fr-), not the guides engine. There is no second markdown implementation; the difference is syntax and where it runs.
Available:
**bold**,*italic*,~~strikethrough~~- inline code and fenced code, highlighted server-side
>quotes- lists — indentation is stripped by
cleanMarkdown()(.replace(/\n[ \t]+/g, '\n')), so never nest a list and never indent a code block - tables
---- headings, with
fr-h-<n>ids - links and images,
http/httpsor site-relative only - autolinked URLs (
linkify: true) ::: note,::: info,::: tip,::: warning::: added,::: changed,::: fixed,::: removed,::: security— moderators only, gated onenv.changelog, which is decided from the author's standing so a post looks the same to every reader::: steps "Title" recordings/<slug>.rec.json— a published recording, matched by basename and always served from/docs/recordings/<slug>.rec.json, whatever folder the path was written with::: videoand::: spoiler
breaks: true, so one newline is a line break. html: false, so a tag a member types is shown as characters rather than removed.
Not available: Obsidian > [!note] callouts, wikilinks, ::: tabs, ::: danger / ::: success / ::: example, relative images, and nested lists. Those belong to the guides engine.
The toolbar's changelog row is blocks: true only in the update editor; forum.js and thread.js both pass blocks: false, and test/ui-shape.test.js checks that every editor declares which it wants.
See also Markdown reference and Markdown blocks.
How this is tested
test/forum.test.js (994 lines) drives the real routers over HTTP with three sessions. Its cases:
- reading the forum needs no account; writing does
- a thread in a support category is a ticket, and one elsewhere is not
- only a moderator moves a ticket, and every move is recorded; an unknown status is refused rather than stored; moving into support gives it a status and moving out takes it away
- a status change reaches the people following the ticket; a reply does not notify the person who wrote it
- a locked thread stops replies from everybody but the moderators
- removing a post is a moderator's and removing the question is an admin's
- a member can edit their own post and nobody else's
- markdown is rendered on the server and cannot smuggle anything; the preview is the same renderer the post will be, and promises exactly what the post will be; only staff can wear a changelog badge; callouts are not collateral damage
- the ticket queue is the moderators' business only
- a search answers with threads and posts
- a category is created, renamed and cannot be deleted out from under its threads
- changelog entries are siblings, not nested, however they are written
- a long thread is a window, and says so
- a refused write is refused for the reason it was refused
- moderation and writing are separate gates on every route
The four update cases in the same file live in Updates.
test/forum-render.test.js checks the rendered pages: the index renders its categories, the ticket and the sidebar; an empty forum says so instead of showing a blank column; the shared helpers load before the page scripts that need them; a ticket renders its status, its note and the trail of who moved it; a reader gets no moderation controls and no queue; an anonymous reader is offered a sign-in, not a dead composer.
test/forum-pages.test.js checks the three pages as files: they exist and are in the header sync list, they carry the shared header and footer, the header marks the forum as current on all three, the server serves exactly the URLs the nav claims, /forum and /updates are in the sitemap while /api is not, the ticket states match on both sides of the wire, the update kinds do too, a member's profile lists their threads, the front page carries the updates and open tickets server-rendered, a signed-out reader gets a page rather than a gate, and the pages load their scripts in the same order.
See also Testing.