7253068fee
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
584 lines
19 KiB
Markdown
584 lines
19 KiB
Markdown
# 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=<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
|
||
|
||
```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 <token>` (obtained from `POST /api/auth/login`)
|
||
- **API Key**: `X-Api-Key: sk_<key>` (Service accounts only)
|
||
|
||
Base URL: `https://tickets.thewrightserver.net/api`
|
||
|
||
`POST /api/auth/login` is rate-limited to 10 attempts per 15 minutes per IP.
|
||
|
||
---
|
||
|
||
### 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=<query>&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=<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.
|
||
|
||
```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=<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.
|
||
|
||
```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_<goddard 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": "<Goddard 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 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 |
|