josh a9bf332369
Build & Push / Test (client) (push) Successful in 33s
Build & Push / Test (server) (push) Successful in 25s
Build & Push / Build Client (push) Successful in 42s
Build & Push / Build Server (push) Successful in 1m5s
Retheme UI from blue to neutral zinc backgrounds with indigo accents
Removes the blue tint from all dark-mode surfaces by switching CSS
variables to zinc-based neutrals, and replaces decorative blue classes
with indigo across buttons, focus rings, tabs, and links. Semantic blue
(severity badges, status badges, role badges, timeline markers) is
preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 13:29:50 -04:00
2026-04-18 22:44:32 -04:00
2026-03-30 19:38:32 -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%