Phase 5: ship (healthz, CI test gates, v1.0 README)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 16:42:47 -04:00
parent ef22e92ac8
commit 7253068fee
4 changed files with 258 additions and 87 deletions
+37 -4
View File
@@ -10,8 +10,8 @@ env:
OWNER: ${{ github.repository_owner }} OWNER: ${{ github.repository_owner }}
jobs: jobs:
typecheck-client: test-client:
name: TypeScript Check (client) name: Test (client)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -27,11 +27,44 @@ jobs:
working-directory: ./client working-directory: ./client
- name: Type check - name: Type check
run: npx tsc --noEmit run: npm run typecheck
working-directory: ./client 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: build-server:
name: Build Server name: Build Server
needs: test-server
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -59,7 +92,7 @@ jobs:
build-client: build-client:
name: Build Client name: Build Client
needs: typecheck-client needs: test-client
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
+196 -68
View File
@@ -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 - **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 - **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 - **Status lifecycle** — Open → In Progress → Resolved → Closed; resolved tickets auto-close after 14 days
- **Queue filter** — filter dashboard by category (queue) - **Full-text search** — Postgres-native tsvector search across tickets and comments
- **My Tickets** — dedicated view of tickets assigned to you - **Saved views** — per-user filter presets on the tickets list
- **Comments** — threaded markdown comments per ticket with author avatars - **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) - **Roles** — Admin, Agent, User, Service (API key auth for automation)
- **Audit log** — every action tracked with actor, timestamp, and expandable detail - **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 - **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 | | 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) | | **Agent** | Manage tickets — create, update, assign, comment, change status (not Closed) |
| **User** | Basic access — view tickets and add comments only | | **User** | Basic access — view tickets and add comments only |
| **Service** | Automation account — authenticates via API key, no password login | | **Service** | Automation account — authenticates via API key, no password login |
@@ -60,6 +69,14 @@ POSTGRES_PASSWORD=<strong password>
JWT_SECRET=<output of: openssl rand -hex 64> JWT_SECRET=<output of: openssl rand -hex 64>
CLIENT_URL=http://tickets.thewrightserver.net CLIENT_URL=http://tickets.thewrightserver.net
PORT=3080 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. Point NPM at `http://<host-ip>:3080` for the proxy host.
@@ -71,6 +88,8 @@ docker compose pull
docker compose up -d 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) ### 4. Seed (first deploy only)
```bash ```bash
@@ -82,6 +101,17 @@ This creates:
- `admin` user (password: `admin123`) — **change this immediately** - `admin` user (password: `admin123`) — **change this immediately**
- `goddard` service account — API key is printed to the console; copy it now - `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 ## Development
@@ -109,9 +139,11 @@ docker run -d \
cd server cd server
cp .env.example .env # set DATABASE_URL and JWT_SECRET cp .env.example .env # set DATABASE_URL and JWT_SECRET
npm install 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 db:seed # seeds admin + Goddard + sample CTI
npm run dev # http://localhost:3000 npm run dev # http://localhost:3000
npm test # vitest (service layer)
npm run typecheck
``` ```
### Client ### Client
@@ -120,19 +152,25 @@ npm run dev # http://localhost:3000
cd client cd client
npm install npm install
npm run dev # http://localhost:5173 (proxies /api to :3000) 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 ## 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 <token>` (obtained from `POST /api/auth/login`) - **JWT**: `Authorization: Bearer <token>` (obtained from `POST /api/auth/login`)
- **API Key**: `X-Api-Key: sk_<key>` (Service accounts only) - **API Key**: `X-Api-Key: sk_<key>` (Service accounts only)
Base URL: `https://tickets.thewrightserver.net/api` Base URL: `https://tickets.thewrightserver.net/api`
`POST /api/auth/login` is rate-limited to 10 attempts per 15 minutes per IP.
--- ---
### Authentication ### Authentication
@@ -172,19 +210,33 @@ Returns the currently authenticated user.
#### `GET /api/tickets` #### `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:** **Query parameters:**
| Parameter | Type | Description | | Parameter | Type | Description |
| ------------ | ------ | ------------------------------------------------------------- | | ------------ | ------ | --------------------------------------------------------------------- |
| `status` | string | Filter by status: `OPEN`, `IN_PROGRESS`, `RESOLVED`, `CLOSED` | | `status` | string | Filter by status: `OPEN`, `IN_PROGRESS`, `RESOLVED`, `CLOSED` |
| `severity` | number | Filter by severity: `1``5` | | `severity` | number | Filter by severity: `1``5` |
| `categoryId` | string | Filter by category (queue) | | `categoryId` | string | Filter by category (queue) |
| `assigneeId` | string | Filter by assignee user ID | | `assigneeId` | string | Filter by assignee user ID |
| `search` | string | Full-text search on title, overview, and display 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. > 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. All changes are recorded in the audit log automatically.
**Response:** Updated ticket object.
--- ---
#### `DELETE /api/tickets/:id` #### `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=<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.
--- ---
@@ -255,23 +391,15 @@ Delete a ticket and all associated comments and audit logs. **Admin only.**
#### `POST /api/tickets/:id/comments` #### `POST /api/tickets/:id/comments`
Add a comment to a ticket. Supports markdown. All authenticated roles may comment. Add a comment. Markdown body. @mentions are parsed and fire notifications to matched users.
**Body:**
```json ```json
{ "body": "string (markdown)" } { "body": "string (markdown)" }
``` ```
**Response:** Created comment object (201).
---
#### `DELETE /api/tickets/:id/comments/:commentId` #### `DELETE /api/tickets/:id/comments/:commentId`
Delete a comment. Authors may delete their own comments; Admins may delete any. Delete a comment. Authors may delete their own; Admins may delete any.
**Response:** 204 No Content.
--- ---
@@ -281,24 +409,10 @@ Delete a comment. Authors may delete their own comments; Admins may delete any.
Retrieve the full audit log for a ticket, ordered newest first. 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 types:**
| Action | Detail | | Action | Detail |
| ------------------ | -------------------------------------------------------------- | | --------------------- | -------------------------------------------------------------- |
| `CREATED` | — | | `CREATED` | — |
| `STATUS_CHANGED` | e.g. `Open → In Progress` | | `STATUS_CHANGED` | e.g. `Open → In Progress` |
| `SEVERITY_CHANGED` | e.g. `SEV 3 → SEV 1` | | `SEVERITY_CHANGED` | e.g. `SEV 3 → SEV 1` |
@@ -308,15 +422,15 @@ Retrieve the full audit log for a ticket, ordered newest first.
| `OVERVIEW_CHANGED` | — | | `OVERVIEW_CHANGED` | — |
| `COMMENT_ADDED` | Comment body | | `COMMENT_ADDED` | Comment body |
| `COMMENT_DELETED` | Deleted comment body | | `COMMENT_DELETED` | Deleted comment body |
| `ATTACHMENT_ADDED` | Filename |
| `ATTACHMENT_REMOVED` | Filename |
--- ---
### CTI (Category / Type / Item) ### CTI (Category / Type / Item)
#### `GET /api/cti/categories` #### `GET /api/cti/categories`
#### `GET /api/cti/types?categoryId=<id>` #### `GET /api/cti/types?categoryId=<id>`
#### `GET /api/cti/items?typeId=<id>` #### `GET /api/cti/items?typeId=<id>`
Read the CTI hierarchy. Used to resolve IDs when creating/rerouting tickets. 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` #### `PATCH /api/users/:id`
Update a user. Update a user — including `notificationPrefs` and `regenerateApiKey`.
```json
{
"displayName": "string",
"email": "string",
"password": "string",
"role": "ADMIN | AGENT | USER | SERVICE",
"regenerateApiKey": true
}
```
#### `DELETE /api/users/:id` #### `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` | | `JWT_SECRET` | Yes | Secret for signing JWTs — use `openssl rand -hex 64` |
| `CLIENT_URL` | Yes | Allowed CORS origin (your domain) | | `CLIENT_URL` | Yes | Allowed CORS origin (your domain) |
| `PORT` | No | Server port (default: `3000`) | | `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 | | `REGISTRY` | Deploy | Container registry hostname |
| `POSTGRES_PASSWORD` | Deploy | Postgres password | | `POSTGRES_PASSWORD` | Deploy | Postgres password |
| `TAG` | Deploy | Image tag to deploy (default: `latest`) | | `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. > 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 |
+6
View File
@@ -42,6 +42,12 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy 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: networks:
- internal - internal
+4
View File
@@ -34,6 +34,10 @@ app.use(pinoHttp({ logger }));
app.use(cors({ origin: process.env.CLIENT_URL || 'http://localhost:5173' })); app.use(cors({ origin: process.env.CLIENT_URL || 'http://localhost:5173' }));
app.use(express.json()); app.use(express.json());
app.get('/healthz', (_req, res) => {
res.json({ status: 'ok' });
});
const loginLimiter = rateLimit({ const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 10, max: 10,