diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 6e519c6..e82451d 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -10,8 +10,8 @@ env: OWNER: ${{ github.repository_owner }} jobs: - typecheck-client: - name: TypeScript Check (client) + test-client: + name: Test (client) runs-on: ubuntu-latest steps: - name: Checkout @@ -27,11 +27,44 @@ jobs: working-directory: ./client - name: Type check - run: npx tsc --noEmit + run: npm run typecheck working-directory: ./client + - name: Unit tests + run: npm test + working-directory: ./client + + test-server: + name: Test (server) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install dependencies + run: npm ci + working-directory: ./server + + - name: Prisma generate + run: npx prisma generate + working-directory: ./server + + - name: Type check + run: npm run typecheck + working-directory: ./server + + - name: Unit tests + run: npm test + working-directory: ./server + build-server: name: Build Server + needs: test-server runs-on: ubuntu-latest steps: - name: Checkout @@ -59,7 +92,7 @@ jobs: build-client: name: Build Client - needs: typecheck-client + needs: test-client runs-on: ubuntu-latest steps: - name: Checkout diff --git a/README.md b/README.md index 2dde508..255f439 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,21 @@ Internal ticketing system with CTI-based routing, severity levels, role-based ac - **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 -- **Queue filter** — filter dashboard by category (queue) -- **My Tickets** — dedicated view of tickets assigned to you -- **Comments** — threaded markdown comments per ticket with author avatars +- **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 and the full CTI hierarchy via UI +- **Admin panel** — manage users, CTI hierarchy, and webhooks via UI - **n8n ready** — service accounts authenticate via `X-Api-Key` header --- @@ -21,7 +30,7 @@ Internal ticketing system with CTI-based routing, severity levels, role-based ac | Role | Access | | ----------- | ---------------------------------------------------------------------------- | -| **Admin** | Full access — manage users, CTI config, close and delete tickets | +| **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 | @@ -60,6 +69,14 @@ 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. @@ -71,6 +88,8 @@ 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 @@ -82,6 +101,17 @@ 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 @@ -109,9 +139,11 @@ docker run -d \ cd server cp .env.example .env # set DATABASE_URL and JWT_SECRET npm install -npm run db:migrate # creates tables +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 @@ -120,19 +152,25 @@ npm run dev # http://localhost:3000 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/*`) require authentication via one of: +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` +`POST /api/auth/login` is rate-limited to 10 attempts per 15 minutes per IP. + --- ### Authentication @@ -172,19 +210,33 @@ Returns the currently authenticated user. #### `GET /api/tickets` -List all tickets, sorted by severity (ASC) then created date (DESC). +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 | -| `search` | string | Full-text search on title, overview, and display ID | +| 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:** Array of ticket objects with nested `category`, `type`, `item`, `assignee`, `createdBy`, and `_count.comments`. +**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. --- @@ -222,32 +274,116 @@ Update a ticket. Accepts any combination of fields. Requires **Agent**, **Admin* > Setting `status` to `CLOSED` requires **Admin** role. -**Body (all fields optional):** - -```json -{ - "title": "string", - "overview": "string (markdown)", - "severity": 3, - "status": "IN_PROGRESS", - "assigneeId": "string | null", - "categoryId": "string", - "typeId": "string", - "itemId": "string" -} -``` - All changes are recorded in the audit log automatically. -**Response:** Updated ticket object. - --- #### `DELETE /api/tickets/:id` -Delete a ticket and all associated comments and audit logs. **Admin only.** +Delete a ticket and all associated comments, audit logs, and attachments. **Admin only.** -**Response:** 204 No Content. +--- + +#### `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. --- @@ -255,23 +391,15 @@ Delete a ticket and all associated comments and audit logs. **Admin only.** #### `POST /api/tickets/:id/comments` -Add a comment to a ticket. Supports markdown. All authenticated roles may comment. - -**Body:** +Add a comment. Markdown body. @mentions are parsed and fire notifications to matched users. ```json { "body": "string (markdown)" } ``` -**Response:** Created comment object (201). - ---- - #### `DELETE /api/tickets/:id/comments/:commentId` -Delete a comment. Authors may delete their own comments; Admins may delete any. - -**Response:** 204 No Content. +Delete a comment. Authors may delete their own; Admins may delete any. --- @@ -281,42 +409,28 @@ Delete a comment. Authors may delete their own comments; Admins may delete any. Retrieve the full audit log for a ticket, ordered newest first. -**Response:** Array of audit log entries: - -```json -[ - { - "id": "...", - "action": "COMMENT_ADDED", - "detail": "Comment body text here", - "createdAt": "2026-03-30T10:00:00Z", - "user": { "id": "...", "username": "admin", "displayName": "Admin" } - } -] -``` - **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 | +| 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. @@ -365,17 +479,7 @@ Service accounts receive an auto-generated API key returned in the response. Cop #### `PATCH /api/users/:id` -Update a user. - -```json -{ - "displayName": "string", - "email": "string", - "password": "string", - "role": "ADMIN | AGENT | USER | SERVICE", - "regenerateApiKey": true -} -``` +Update a user — including `notificationPrefs` and `regenerateApiKey`. #### `DELETE /api/users/:id` @@ -423,6 +527,13 @@ To regenerate the Goddard API key: Admin → Users → refresh icon next to Godd | `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`) | @@ -453,3 +564,20 @@ OPEN → IN_PROGRESS → RESOLVED ──(14 days)──→ CLOSED ``` > 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 | diff --git a/docker-compose.yml b/docker-compose.yml index 934dbab..184d9a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,12 @@ services: depends_on: postgres: condition: service_healthy + healthcheck: + test: ['CMD-SHELL', 'wget -qO- http://localhost:3000/healthz || exit 1'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s networks: - internal diff --git a/server/src/index.ts b/server/src/index.ts index bf28548..09169b6 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -34,6 +34,10 @@ app.use(pinoHttp({ logger })); app.use(cors({ origin: process.env.CLIENT_URL || 'http://localhost:5173' })); app.use(express.json()); +app.get('/healthz', (_req, res) => { + res.json({ status: 'ok' }); +}); + const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10,