Support ticket system for Flarum. Users open tickets, staff handle them in a private threaded view. Banned users can submit ban appeals with rate limits.
composer require linkrobins/support
Then enable it in your forum's admin panel under Extensions.
A private support-desk extension for Flarum 2. Lets registered users open support tickets with staff, with an emphasis on workflows that keep forum-wide moderation actions (suspensions, bans) honest.
is_appeal flag.⋯ menu in the reply header.
Edits stamp edited_at and edited_by_user_id so other staff can
see the audit info; permanent deletion requires the reply to be
soft-deleted first (no accidental single-click destruction).⋯
menu in the ticket title row. Soft-deleted tickets are hidden from
the index for both the owner and staff but remain reachable via
direct URL for staff to restore. Permanent deletion is admin-only
and cascades to all replies.support_appeal_banned)
that blocks appeals while leaving general tickets available. Toggled
from the admin's "Appeal bans" tab.decision field (approved / rejected / null).fof/upload. When
installed, the compose and reply forms surface an "Attach files"
button that uploads through fof/upload's normal pipeline. No
configuration here; the button respects whatever fof/upload
permissions you've set.The extension adds one permission:
linkrobins-support.handle_tickets (default: moderate group) -- grants
the ability to see all tickets, reply on any ticket, post internal
notes, change ticket status, set decisions, and claim tickets.Anyone in the admin group bypasses this check.
Filing tickets requires being authenticated; the policy doesn't add a separate permission for it.
Settings live at admin → Extensions → Link Robins Support, with three tabs:
Users see:
/support -- their tickets list, with filter chips for status./support/new -- compose form. Banned-from-appeals users see only
general categories; suspended users see only appeal categories./support/:id -- the ticket page, with reply form and reply thread.Staff additionally see:
Three tables:
linkrobins_support_categories -- name, slug, description, color,
icon, position, is_appeal.linkrobins_support_tickets -- category_id, user_id,
assigned_staff_id, subject, status, decision, last_reply_at,
deleted_at.linkrobins_support_replies -- ticket_id, user_id, content
(parsed-source XML), is_internal_note, deleted_at, edited_at,
edited_by_user_id.One column added to the existing users table:
support_appeal_banned (boolean, default 0).Replies use Flarum's content formatter via the HasFormattedContent
trait. The rendered HTML is computed at serialize time via
formatContent(), NOT cached in a content_html column -- this means
formatter extensions like mentions and emoji apply to older replies the
moment they're installed.
If fof/upload is installed
and enabled, the compose form and reply form get an "Attach files"
button. Uploaded files are stored, validated, and rendered by
fof/upload; this extension only inserts the resulting BBCode marker
into the message body. No additional configuration is needed -- if
the user has permission to upload via fof/upload, the button
appears.
fof/upload's download URLs act as capabilities: anyone who has the
URL to a file can download it. CSRF protection limits direct
hot-linking, but if a staff member copies a file URL out of a ticket
and shares it elsewhere, that link works for anyone who clicks it.
This is identical to how fof/upload behaves on regular discussions,
so it isn't unique to this extension. If you need a hard guarantee
that ticket attachments can be read only by ticket-eligible users,
fof/upload would need to be patched to gate downloads against
per-resource policies. That's out of scope for v1.
In practice, for the support-desk use case, the risk is small: attachments tend to be screenshots and logs from the ticket-opener themselves, who is also the only non-staff party with the URL.
creating() hooks on both tickets and replies overwrite
user_id with the authenticated actor's id. Even if the client
sends relationships.user, JSON:API rejects it because the field
isn't declared writable, AND the hook would overwrite it anyway.scope() (for single-resource Show endpoints) and the
Searcher's getQuery() (for list Index endpoints). Both use the
same rules.is_internal_note), not at render time. A non-staff user
hitting the API directly cannot bypass the filter.->can('update'), which routes
to SupportTicketPolicy::update. Field setters add a second layer
of defense: even if the gate ever loosened, status / decision /
assignment changes wouldn't take effect for a non-staff actor.withTrashed() in the resource scope, but Index/Searcher
queries deliberately stay on the active set so the staff index
isn't cluttered. Force-delete on a live (non-trashed) row is
rejected by the deleting() hook -- a soft-delete must come
first.| Endpoint | Method | Auth |
|---|---|---|
/api/linkrobins-support-categories |
GET | public |
/api/linkrobins-support-categories |
POST | admin |
/api/linkrobins-support-categories/:id |
PATCH/DELETE | admin |
/api/linkrobins-support-tickets |
GET | authenticated |
/api/linkrobins-support-tickets |
POST | authenticated |
/api/linkrobins-support-tickets/:id |
GET | per-policy |
/api/linkrobins-support-tickets/:id |
PATCH | staff (handle_tickets) |
/api/linkrobins-support-tickets/:id |
DELETE | admin, soft-deleted only |
/api/linkrobins-support-replies |
GET | authenticated |
/api/linkrobins-support-replies |
POST | authenticated |
/api/linkrobins-support-replies/:id |
PATCH | staff (handle_tickets) |
/api/linkrobins-support-replies/:id |
DELETE | staff, soft-deleted only |
Moderation patterns:
{ attributes: { content: "..." } } to
edit (replies only). Stamps edited_at + edited_by_user_id.{ attributes: { isDeleted: true } } to soft-delete.
PATCH with { isDeleted: false } to restore.Supported filters (use filter[name]=value shape; Flarum 2 rejects
unrecognized top-level params):
filter[mine]=1, filter[status]=open,
filter[categoryId]=Nfilter[ticketId]=N