# 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/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, Service (API key auth for automation) - **Audit log** — every action tracked with actor, timestamp, and expandable detail - **Admin panel** — manage users, CTI hierarchy, and webhooks via UI - **n8n ready** — service accounts authenticate via `X-Api-Key` header --- ## 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) | | **User** | Basic access — view tickets and add comments only | | **Service** | Automation account — authenticates via API key, no password login | > 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 ```bash scp docker-compose.yml .env.example user@your-server:~/ticketing/ ``` ### 2. Configure environment ```bash cd ~/ticketing cp .env.example .env ``` Edit `.env`: ```env REGISTRY=gitea.thewrightserver.net TAG=latest POSTGRES_PASSWORD= JWT_SECRET= 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_FROM=tickets@thewrightserver.net SMTP_SECURE=false ``` Point NPM at `http://:3080` for the proxy host. ### 3. Deploy ```bash 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) ```bash docker compose exec server npm run db:seed ``` This creates: - `admin` user (password: `admin123`) — **change this immediately** - `goddard` service account — API key is printed to the console; copy it now ### 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. ```bash 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 ```bash 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 ```bash 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 + Goddard + sample CTI npm run dev # http://localhost:3000 npm test # vitest (service layer) npm run typecheck ``` ### Client ```bash 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 ` (obtained from `POST /api/auth/login`) - **API Key**: `X-Api-Key: sk_` (Service accounts only) Base URL: `https://tickets.thewrightserver.net/api` --- ### Authentication #### `POST /api/auth/login` Authenticate and receive a JWT. **Body:** ```json { "username": "string", "password": "string" } ``` **Response:** ```json { "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):** ```json { "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 the Goddard integration. --- #### `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**, **Admin**, or **Service** role. **Body:** ```json { "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**, **Admin**, or **Service** 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**. ```json { "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=&limit=10` Cross-resource search over tickets and comments using Postgres `plainto_tsquery` with rank ordering. **Response:** ```json { "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): ```json { "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=`, 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. ```json { "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=` #### `GET /api/cti/items?typeId=` 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. ```json { "username": "string", "email": "string", "displayName": "string", "password": "string (not required for SERVICE role)", "role": "ADMIN | AGENT | USER | SERVICE" } ``` Service accounts receive an auto-generated API key returned in the response. Copy it immediately — it is not shown again. #### `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 (Goddard) The `goddard` service account authenticates via API key — no login flow needed. **Create a ticket from n8n:** ``` POST /api/tickets X-Api-Key: sk_ Content-Type: application/json { "title": "[Plex] Backup - 2026-03-30T02:00:00", "overview": "Automated nightly Plex backup completed.", "severity": 5, "categoryId": "", "typeId": "", "itemId": "", "assigneeId": "" } ``` CTI IDs can be fetched from: - `GET /api/cti/categories` - `GET /api/cti/types?categoryId=` - `GET /api/cti/items?typeId=` To regenerate the Goddard API key: Admin → Users → refresh icon next to Goddard. --- ## 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 |