Split the dense single-row filter bar into two rows: search + saved views on top, filter selectors below. Fix CTI selectors to use design system tokens instead of hardcoded dark classes, and upgrade the saved views button with an icon and badge count. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
TicketingSystem
Internal ticketing system with CTI-based routing, severity levels, role-based access, and automation integration.
Features
- CTI routing — tickets categorised by Category → Type → Item, reroutable at any time
- Severity 1–5 — SEV 1 (critical) through SEV 5 (minimal); dashboard sorts by severity
- Status lifecycle — Open → In Progress → Resolved → Closed; resolved tickets auto-close after 14 days
- Full-text search — Postgres-native tsvector search across tickets and comments
- Saved views — per-user filter presets on the tickets list
- Pagination + bulk actions — page through large result sets and reassign / close / reprioritise in batches
- Attachments — upload files on tickets and comments (25 MB each, local disk)
- Notifications — in-app bell + optional SMTP email on assignment, @mention, and resolution
- Outgoing webhooks — HMAC-signed POSTs on ticket/comment events, with per-endpoint secret and retries
- @mentions — autocomplete in comments; triggers notifications and deep-links to the mentioned user's queue
- Analytics dashboard — open-by-severity, aging tickets, resolution time, queue load
- CSV export — stream any filtered ticket view to CSV
- Command palette + keyboard shortcuts — ⌘K palette,
j/knavigation,g d/t/m/n/sleaders,?help - PWA — installable on mobile, offline app shell
- Comments — GitHub/Gitea-style threads with markdown, draft autosave, Ctrl+Enter submit
- Roles — Admin, Agent, User
- Audit log — every action tracked with actor, timestamp, and expandable detail
- Admin panel — manage users, CTI hierarchy, and webhooks via UI
- n8n ready — every Agent gets an auto-generated API key for
X-Api-Keyheader auth
Roles
| Role | Access |
|---|---|
| Admin | Full access — manage users, CTI config, webhooks, close and delete tickets |
| Agent | Manage tickets — create, update, assign, comment, change status (not Closed). Logs in with password and can authenticate via X-Api-Key header (key shown once at creation) |
| User | Basic access — view tickets and add comments only |
Only Admins can manually set a ticket status to Closed.
Production Deployment
Prerequisites
- Docker + Docker Compose
- Nginx Proxy Manager pointed at the host port (default
3080) - Access to your Gitea container registry
1. Copy files to your server
scp docker-compose.yml .env.example user@your-server:~/ticketing/
2. Configure environment
cd ~/ticketing
cp .env.example .env
Edit .env:
REGISTRY=gitea.thewrightserver.net
TAG=latest
POSTGRES_PASSWORD=<strong password>
JWT_SECRET=<output of: openssl rand -hex 64>
CLIENT_URL=http://tickets.thewrightserver.net
PORT=3080
# Optional — email notifications
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS=<smtp password>
SMTP_FROM=tickets@thewrightserver.net
SMTP_SECURE=false
Point NPM at http://<host-ip>:3080 for the proxy host.
3. Deploy
docker compose pull
docker compose up -d
The server exposes an unauthenticated GET /healthz that returns {"status":"ok"} and is polled by the compose healthcheck.
4. Seed (first deploy only)
docker compose exec server npm run db:seed
This creates:
adminuser (password:admin123) — change this immediately- Sample CTI hierarchy (categories, types, items)
Automation accounts are no longer seeded. Create an Agent via Admin → Users to get an API key for n8n / scripts — the key is shown once in a modal at creation time.
Upgrading from v0.9
v1.0 is drop-in for existing deployments — the schema changes are all additive (new tables for attachments, webhooks, notifications, saved views; new columns for notification prefs and search vectors). No data migration is required.
docker compose pull
docker compose up -d
npm run start:prod runs prisma db push on boot, which applies the new tables and the search-index migration automatically. Set the new SMTP_* env vars if you want email notifications — otherwise they're silently skipped.
Development
Requirements
- Node.js 22+
- PostgreSQL (local or via Docker)
Start Postgres
docker run -d \
--name ticketing-pg \
-e POSTGRES_DB=ticketing \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \
postgres:16-alpine
Server
cd server
cp .env.example .env # set DATABASE_URL and JWT_SECRET
npm install
npm run db:push # creates tables + search indexes
npm run db:seed # seeds admin user + sample CTI
npm run dev # http://localhost:3000
npm test # vitest (service layer)
npm run typecheck
Client
cd client
npm install
npm run dev # http://localhost:5173 (proxies /api to :3000)
npm test # vitest + testing-library
npm run typecheck
CI runs typecheck + tests on both packages before building Docker images.
API Reference
All endpoints (except /api/auth/* and /healthz) require authentication via one of:
- JWT:
Authorization: Bearer <token>(obtained fromPOST /api/auth/login) - API Key:
X-Api-Key: sk_<key>(on any Agent account)
Base URL: https://tickets.thewrightserver.net/api
Authentication
POST /api/auth/login
Authenticate and receive a JWT.
Body:
{ "username": "string", "password": "string" }
Response:
{
"token": "eyJ...",
"user": {
"id": "...",
"username": "admin",
"displayName": "Admin",
"email": "...",
"role": "ADMIN"
}
}
GET /api/auth/me
Returns the currently authenticated user.
Tickets
GET /api/tickets
List tickets, sorted by severity (ASC) then created date (DESC).
Query parameters:
| Parameter | Type | Description |
|---|---|---|
status |
string | Filter by status: OPEN, IN_PROGRESS, RESOLVED, CLOSED |
severity |
number | Filter by severity: 1–5 |
categoryId |
string | Filter by category (queue) |
assigneeId |
string | Filter by assignee user ID |
createdById |
string | Filter by author |
search |
string | Full-text search (tsvector) on title, overview, and display ID |
page |
number | Page number, 1-indexed. When omitted the endpoint returns a bare array (v0.9 shape) |
pageSize |
number | Page size (default 25, max 100) |
Response (paginated):
{
"data": [ /* tickets */ ],
"total": 134,
"page": 1,
"pageSize": 25
}
Response (unpaginated — page omitted): Array of ticket objects with nested category, type, item, assignee, createdBy, and _count.comments. This preserves compatibility with v0.9 clients and API-key integrations.
GET /api/tickets/:id
Fetch a single ticket by internal ID or display ID (e.g. V325813929). Includes full comments array.
POST /api/tickets
Create a new ticket. Requires Agent or Admin role.
Body:
{
"title": "string",
"overview": "string (markdown)",
"severity": 1,
"categoryId": "string",
"typeId": "string",
"itemId": "string",
"assigneeId": "string (optional)"
}
Response: Created ticket object (201).
PATCH /api/tickets/:id
Update a ticket. Accepts any combination of fields. Requires Agent or Admin role.
Setting
statustoCLOSEDrequires Admin role.
All changes are recorded in the audit log automatically.
DELETE /api/tickets/:id
Delete a ticket and all associated comments, audit logs, and attachments. Admin only.
POST /api/tickets/bulk
Apply an action to many tickets at once. Agent or Admin.
{ "ids": ["..."], "action": "reassign" | "close" | "setSeverity", "value": "..." }
GET /api/export/tickets.csv
Stream the matching tickets (same filter params as GET /api/tickets) as CSV.
Search
GET /api/search?q=<query>&limit=10
Cross-resource search over tickets and comments using Postgres plainto_tsquery with rank ordering.
Response:
{
"tickets": [ /* ticket summaries */ ],
"comments": [ /* comment snippets with ticket context */ ]
}
Analytics
GET /api/analytics/summary?window=30
Returns aggregates for the given window (days — 14 / 30 / 90):
{
"openBySeverity": { "1": 2, "2": 5, "3": 14, "4": 8, "5": 3 },
"aging": { "over7d": 6, "over14d": 2 },
"queueLoad": [ { "categoryId": "...", "name": "TheWrightServer", "open": 12 } ],
"medianResolutionHours": 9.4
}
Saved Views
Per-user filter presets.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/saved-views |
List the caller's views |
POST |
/api/saved-views |
Create — { name, filters } |
PATCH |
/api/saved-views/:id |
Rename / edit filters |
DELETE |
/api/saved-views/:id |
Delete |
Notifications
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/notifications |
Caller's notifications, newest first |
POST |
/api/notifications/read |
Mark specific or all — returns { updated } |
Notifications are created on assignment, @mention, and status → RESOLVED. Email delivery is attempted when SMTP is configured and the target user has email notifications enabled in User.notificationPrefs.
Attachments
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/tickets/:id/attachments |
Upload (multipart, field file) |
POST |
/api/comments/:id/attachments |
Upload on a comment |
GET |
/api/attachments/:id |
Stream with original filename |
DELETE |
/api/attachments/:id |
Uploader or Admin |
Size limit 25 MB per file. Storage path is controlled by UPLOADS_DIR (defaults to ./uploads in dev, /data/uploads in the Docker image backed by the uploads volume).
Webhooks
Admin-only. Outgoing webhooks fire on ticket.created, ticket.status_changed, ticket.assigned, and comment.created.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/webhooks |
List |
POST |
/api/webhooks |
Create — { name, url, events, secret? } |
PATCH |
/api/webhooks/:id |
Update |
DELETE |
/api/webhooks/:id |
Delete |
POST |
/api/webhooks/:id/rotate-secret |
Rotate secret |
Signature: each delivery includes X-Ticketing-Signature: sha256=<hex>, which is the HMAC-SHA256 of the raw request body using the webhook's secret. Retries up to 3× with exponential backoff on non-2xx responses.
Comments
POST /api/tickets/:id/comments
Add a comment. Markdown body. @mentions are parsed and fire notifications to matched users.
{ "body": "string (markdown)" }
DELETE /api/tickets/:id/comments/:commentId
Delete a comment. Authors may delete their own; Admins may delete any.
Audit Log
GET /api/tickets/:id/audit
Retrieve the full audit log for a ticket, ordered newest first.
Action types:
| Action | Detail |
|---|---|
CREATED |
— |
STATUS_CHANGED |
e.g. Open → In Progress |
SEVERITY_CHANGED |
e.g. SEV 3 → SEV 1 |
ASSIGNEE_CHANGED |
e.g. Unassigned → Josh |
REROUTED |
e.g. OldCat › OldType › OldItem → NewCat › NewType › NewItem |
TITLE_CHANGED |
New title |
OVERVIEW_CHANGED |
— |
COMMENT_ADDED |
Comment body |
COMMENT_DELETED |
Deleted comment body |
ATTACHMENT_ADDED |
Filename |
ATTACHMENT_REMOVED |
Filename |
CTI (Category / Type / Item)
GET /api/cti/categories
GET /api/cti/types?categoryId=<id>
GET /api/cti/items?typeId=<id>
Read the CTI hierarchy. Used to resolve IDs when creating/rerouting tickets.
Admin-only write operations:
| Method | Endpoint | Body |
|---|---|---|
POST |
/api/cti/categories |
{ "name": "string" } |
PUT |
/api/cti/categories/:id |
{ "name": "string" } |
DELETE |
/api/cti/categories/:id |
— |
POST |
/api/cti/types |
{ "name": "string", "categoryId": "string" } |
PUT |
/api/cti/types/:id |
{ "name": "string" } |
DELETE |
/api/cti/types/:id |
— |
POST |
/api/cti/items |
{ "name": "string", "typeId": "string" } |
PUT |
/api/cti/items/:id |
{ "name": "string" } |
DELETE |
/api/cti/items/:id |
— |
Deleting a category cascades to all child types and items.
Users
GET /api/users
List all users (id, username, displayName, email, role). Used to populate assignee dropdowns.
Admin-only operations:
POST /api/users
Create a user.
{
"username": "string",
"email": "string",
"displayName": "string",
"password": "string (min 8 chars)",
"role": "ADMIN | AGENT | USER"
}
Agent accounts receive an auto-generated API key returned in the response. Copy it immediately — it is not shown again. Use PATCH /api/users/:id with { "regenerateApiKey": true } to rotate.
PATCH /api/users/:id
Update a user — including notificationPrefs and regenerateApiKey.
DELETE /api/users/:id
Delete a user. Cannot delete your own account.
n8n Integration
Create an Agent account via Admin → Users. The API key is shown once in a modal at creation — copy it into n8n's credentials as the X-Api-Key header value. Every Agent can authenticate via both password (for the UI) and API key (for automation).
Create a ticket from n8n:
POST /api/tickets
X-Api-Key: sk_<agent api key>
Content-Type: application/json
{
"title": "[Plex] Backup - 2026-03-30T02:00:00",
"overview": "Automated nightly Plex backup completed.",
"severity": 5,
"categoryId": "<TheWrightServer category ID>",
"typeId": "<Automation type ID>",
"itemId": "<Backup item ID>",
"assigneeId": "<agent user ID>"
}
CTI IDs can be fetched from:
GET /api/cti/categoriesGET /api/cti/types?categoryId=<id>GET /api/cti/items?typeId=<id>
To rotate an Agent's API key: Admin → Users → refresh icon next to the Agent. The old key stops working immediately.
Environment Variables
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | PostgreSQL connection string |
JWT_SECRET |
Yes | Secret for signing JWTs — use openssl rand -hex 64 |
CLIENT_URL |
Yes | Allowed CORS origin (your domain) |
PORT |
No | Server port (default: 3000) |
UPLOADS_DIR |
No | Attachment storage path (default /data/uploads in Docker) |
SMTP_HOST |
No | SMTP host — when set, email notifications are sent |
SMTP_PORT |
No | SMTP port (default 587) |
SMTP_USER |
No | SMTP username |
SMTP_PASS |
No | SMTP password |
SMTP_FROM |
No | From address (default noreply@localhost) |
SMTP_SECURE |
No | true for implicit TLS (port 465), else false |
REGISTRY |
Deploy | Container registry hostname |
POSTGRES_PASSWORD |
Deploy | Postgres password |
TAG |
Deploy | Image tag to deploy (default: latest) |
Ticket Severity
| Level | Label | Meaning |
|---|---|---|
| 1 | SEV 1 | Critical — immediate action required |
| 2 | SEV 2 | High — significant impact |
| 3 | SEV 3 | Medium — standard priority |
| 4 | SEV 4 | Low — minor issue |
| 5 | SEV 5 | Minimal — informational / automated |
Tickets are sorted SEV 1 → SEV 5 on the dashboard.
Ticket Status Lifecycle
OPEN → IN_PROGRESS → RESOLVED ──(14 days)──→ CLOSED
↑
re-opens reset
the 14-day timer
CLOSED status can only be set manually by an Admin. The auto-close job runs hourly.
Keyboard Shortcuts
| Context | Keys | Action |
|---|---|---|
| Global | ⌘K / Ctrl+K |
Command palette |
| Global | ? |
Shortcuts help |
| Global | c |
Create ticket (Agent/Admin) |
| Navigate | g then d / t / m / n / s |
Dashboard / Tickets / My Tickets / Notifications / Settings |
| List | j / k |
Move cursor |
| List | Enter |
Open focused ticket |
| List | x |
Toggle selection |
| Detail | e |
Edit title |
| Detail | r |
Reply (focus composer) |
| Detail | Ctrl+Enter / ⌘Enter |
Submit comment |