SWIFTPOINT//HUB
Support

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_events row with the note Ticket opened.;
  • inserts a forum_subscriptions row 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:

  1. STATUSES in lib/forum.js — the data, and the source of truth;
  2. STATUSES in public/js/thread.js — the four buttons in the status strip;
  3. .st-<value> in public/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.
  • Tickets renders every support category, in configured order, so a ticket keeps its address when it is read.
  • No filter renders all categories in sort order.
  • An empty category renders Nothing here yet.
  • An empty result renders .up-empty with 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 staff tag 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;
  • relTime plus · edited when edited_at is 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-h reading Ticket;
  • the current status badge;
  • set <relTime> by <username> from statusChangedAt / statusChangedBy;
  • the optional statusNote in p.fr-ticket-note;
  • ul.fr-trail, one li per forum_ticket_events row: a status badge, the actor or the system, the time, and — note when 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/reports and POST /api/reports/:id/status are both requireAdmin. 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/threads and 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.

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:

  1. lib/notify.js writes the row into notifications, then publishes the same object to lib/events.js.
  2. lib/events.js is an in-process pub/sub: Map<userId, Set<res>>, writing data: {json}\n\n to every open response for that user. GET /api/notifications/stream is the SSE endpoint (text/event-stream, no-cache, keep-alive, X-Accel-Buffering: no).
  3. public/js/notifications.js polls every 30 s (POLL_MS = 30000) for the unread count and holds an EventSource that 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/https or site-relative only
  • autolinked URLs (linkify: true)
  • ::: note, ::: info, ::: tip, ::: warning
  • ::: added, ::: changed, ::: fixed, ::: removed, ::: security — moderators only, gated on env.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
  • ::: video and ::: 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.