josh cfe7ad56ff
Build & Push / Test (client) (push) Successful in 27s
Build & Push / Test (server) (push) Successful in 31s
Build & Push / Build Client (push) Successful in 1m5s
Build & Push / Build Server (push) Successful in 1m43s
Rework tickets filter bar into two-row layout with consistent CTI styling
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>
2026-04-22 22:20:29 -04:00
2026-04-21 20:45:46 -04:00
2026-04-21 20:45:46 -04:00
2026-04-21 20:45:46 -04:00
2026-04-18 22:44:32 -04:00

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 15 — 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/k navigation, g d/t/m/n/s leaders, ? 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-Key header 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:

  • admin user (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 from POST /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: 15
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 status to CLOSED requires 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.


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/categories
  • GET /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
S
Description
Internal ticketing system with CTI-based routing, severity levels, role-based access, and automation integration.
Readme 550 KiB
Languages
TypeScript 96.6%
JavaScript 1.6%
CSS 0.8%
PLpgSQL 0.6%
Dockerfile 0.3%
Other 0.1%