- .gitignore: add coverage/, .vscode/, .idea/ - .env.example files: add header comments clarifying production vs dev, add SMTP vars to server dev template - Validate SavedView filters on load with safeParse fallback 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 |