# ProjectXYZ — Backend API Reference

Living reference for every HTTP endpoint. Live endpoints below are fully documented; pending ones list signatures only and get expanded as each step ships.

- **Base URL:** `http://localhost:4000/api/v1`
- **Last updated through:** Step 6 — Orders (+ ticket watchers)

---

## Status board

### Live (Step 1 – 6)

| Method | Path                                  | Step |
|--------|---------------------------------------|------|
| GET    | `/health`                             | 1    |
| POST   | `/auth/login`                         | 2    |
| POST   | `/auth/refresh`                       | 2    |
| POST   | `/auth/logout`                        | 2    |
| GET    | `/auth/me`                            | 2    |
| GET    | `/users`                              | 2    |
| POST   | `/users`                              | 2    |
| GET    | `/users/:id`                          | 2    |
| PATCH  | `/users/:id`                          | 2    |
| DELETE | `/users/:id`                          | 2    |
| GET    | `/customers`                          | 3    |
| POST   | `/customers`                          | 3    |
| GET    | `/customers/:id`                      | 3    |
| PATCH  | `/customers/:id`                      | 3    |
| DELETE | `/customers/:id`                      | 3    |
| GET    | `/customers/:customerId/contacts`     | 3    |
| GET    | `/contacts`                           | 3    |
| POST   | `/contacts`                           | 3    |
| GET    | `/contacts/:id`                       | 3    |
| PATCH  | `/contacts/:id`                       | 3    |
| DELETE | `/contacts/:id`                       | 3    |
| GET    | `/assets`                             | 4    |
| POST   | `/assets`                             | 4    |
| GET    | `/assets/:id`                         | 4    |
| PATCH  | `/assets/:id`                         | 4    |
| DELETE | `/assets/:id`                         | 4    |
| GET    | `/tickets`                            | 5a   |
| POST   | `/tickets`                            | 5a   |
| GET    | `/tickets/:id`                        | 5a   |
| PATCH  | `/tickets/:id`                        | 5a   |
| DELETE | `/tickets/:id`                        | 5a   |
| POST   | `/tickets/:id/assets/:assetId`        | 5a   |
| DELETE | `/tickets/:id/assets/:assetId`        | 5a   |
| POST   | `/tickets/:id/watchers/:userId`       | 5a+  |
| DELETE | `/tickets/:id/watchers/:userId`       | 5a+  |
| POST   | `/assets/:id/tickets/:ticketId`       | 5a   |
| DELETE | `/assets/:id/tickets/:ticketId`       | 5a   |
| GET    | `/tickets/:id/events`                 | 5b   |
| POST   | `/tickets/:id/events`                 | 5b   |
| GET    | `/routing-rules`                      | 5b+  |
| POST   | `/routing-rules`                      | 5b+  |
| GET    | `/routing-rules/:id`                  | 5b+  |
| PATCH  | `/routing-rules/:id`                  | 5b+  |
| DELETE | `/routing-rules/:id`                  | 5b+  |
| GET    | `/orders`                             | 6    |
| POST   | `/orders`                             | 6    |
| GET    | `/orders/:id`                         | 6    |
| PATCH  | `/orders/:id`                         | 6    |
| DELETE | `/orders/:id`                         | 6    |
| POST   | `/orders/:id/send`                    | 6    |
| POST   | `/orders/:id/paid`                    | 6    |
| POST   | `/orders/:id/cancel`                  | 6    |

### Pending

| Module        | Endpoints     | Step | Notes                                                              |
|---------------|---------------|------|--------------------------------------------------------------------|
| SLAs          | 4 endpoints   | 5c   | Schema in DB already; engine + endpoints ship together             |
| Attachments   | 4 endpoints   | 5–6  | Backblaze B2 presigned upload, polymorphic owner                   |
| Email gateway | TBD           | 6+   | Inbound webhook + outbound transport (Mailgun/Postmark/SES)        |
| Contracts     | 8 endpoints   | 7    | CRUD + atomic billing trigger + events                             |

---

## Conventions

### Response envelope

Every response uses a consistent shape.

```jsonc
// 2xx
{ "ok": true, ...payload }

// 4xx / 5xx
{
  "ok": false,
  "error": {
    "code": "string",       // machine-readable, e.g. "unauthorized"
    "message": "string",    // human-readable summary
    "details": "?"          // zod issues for 400s, otherwise omitted
  }
}
```

### Authentication

| Header                                | Required for                                | Source                                              |
|---------------------------------------|---------------------------------------------|-----------------------------------------------------|
| `Authorization: Bearer <accessToken>` | All endpoints **except** the four in `/auth/*` below | Returned by `/auth/login` and `/auth/refresh`       |
| `Cookie: rt=...` (httpOnly)           | `/auth/refresh`, `/auth/logout`             | Set automatically by `/auth/login`; path-scoped to `/api/v1/auth` |

The access token carries `wid` (workspace id) and `wrole` (workspace role). Every protected endpoint reads tenant scope from the JWT — no `X-Workspace-Id` header is ever needed.

### Common error codes

| HTTP | `code`           | When                                                                    |
|------|------------------|-------------------------------------------------------------------------|
| 400  | `bad_request`    | Body failed zod validation (`details` contains field errors)            |
| 401  | `unauthorized`   | Missing/invalid bearer, expired token, bad credentials, missing cookie  |
| 403  | `forbidden`      | Authenticated but role doesn't match `requireRole(...)`                 |
| 404  | `not_found`      | Route doesn't exist, or resource not in this workspace                  |
| 409  | `conflict`       | Unique constraint hit (duplicate display code, duplicate email)         |
| 429  | `rate_limited`   | Too many login (5/min) or refresh (30/min) attempts from this IP        |
| 500  | `internal_error` | Unhandled — check server logs                                            |

### Rate limits (current)

- `POST /auth/login` — 5 / IP / minute
- `POST /auth/refresh` — 30 / IP / minute
- Everything else — unlimited (tightened per-endpoint in Step 10)

---

# Live endpoints

## Health

### `GET /health`

Unauthenticated. Reports server liveness and database reachability.

```jsonc
// 200
{
  "ok": true,
  "service": "projectxyz-backend",
  "version": "0.1.0",
  "db": "up",                      // or "down" with HTTP 503 if Postgres is unreachable
  "timestamp": "2026-05-13T12:34:56.789Z"
}
```

---

## Auth

### `POST /auth/login`

**Public.** Rate-limited.

```jsonc
// Request
{
  "email": "admin@projectxyz.local",
  "password": "changeme123"
}

// 200 — also sets an httpOnly `rt` cookie scoped to /api/v1/auth
{
  "ok": true,
  "user": { "id": "uuid", "email": "admin@projectxyz.local", "name": "Admin User" },
  "workspace": { "id": "uuid", "name": "Default Workspace", "slug": "default", "role": "Owner" },
  "accessToken": "eyJhbGciOi..."
}
```

Errors: `400` bad input · `401` invalid credentials (constant-time-ish compare to prevent timing leaks) · `429` rate-limited.

### `POST /auth/refresh`

**Requires `rt` cookie.** Rate-limited.

Empty body. Single-use: revokes the current refresh token and issues a new one (rotation).

```jsonc
// 200 — cookie value is replaced with the rotated one
{
  "ok": true,
  "user": {...},
  "workspace": {...},
  "accessToken": "eyJhbGci..."
}
```

Errors: `401` missing cookie · `401` invalid/expired · `401` **reuse-detected** — if a revoked token is presented again, all refresh tokens for that user are revoked (OWASP recommended).

### `POST /auth/logout`

Best-effort. Reads the `rt` cookie (if any), marks it revoked, clears the cookie.

```jsonc
// 200 — always succeeds even if there was no cookie
{ "ok": true }
```

### `GET /auth/me`

**Bearer.** Returns the authenticated user, their active workspace, and all workspaces they belong to.

```jsonc
// 200
{
  "ok": true,
  "user": { "id": "uuid", "email": "...", "name": "...", "createdAt": "ISO" },
  "activeWorkspace": { "id": "uuid", "name": "Default Workspace", "slug": "default", "role": "Owner" },
  "workspaces": [{ "id": "uuid", "name": "...", "slug": "...", "role": "Owner" }]
}
```

Errors: `401` missing/invalid bearer · `401` active workspace no longer accessible.

---

## Users

All `/users` endpoints require `Authorization: Bearer <accessToken>`. Workspace scope comes from the JWT — listing/getting only returns users who are members of the caller's workspace.

### `GET /users`

List all users in the active workspace, joined with their workspace role.

```jsonc
// 200
{
  "ok": true,
  "users": [
    {
      "id": "uuid",
      "email": "admin@projectxyz.local",
      "name": "Admin User",
      "role": "Owner",
      "createdAt": "2026-05-12T..."
    }
  ]
}
```

### `POST /users`

**Requires role:** `Owner` or `Admin`. Creates a user and adds them to the active workspace as a member with the given role.

```jsonc
// Request
{
  "email": "jane@projectxyz.local",
  "name": "Jane Doe",
  "password": "atLeast8Chars",
  "role": "Agent"        // optional; defaults to "Agent"
}

// 201
{
  "ok": true,
  "user": { "id": "uuid", "email": "jane@projectxyz.local", "name": "Jane Doe", "role": "Agent", "createdAt": "ISO" }
}
```

Errors: `400` bad input · `403` caller not Owner/Admin · `409` email already in use.

### `GET /users/:id`

Detail. 404 if the user isn't a member of this workspace.

```jsonc
// 200
{ "ok": true, "user": { "id": "...", "email": "...", "name": "...", "role": "Agent", "createdAt": "ISO" } }
```

### `PATCH /users/:id`

Update one or more fields. Authorization rules:
- **Self** can update `name`, `email`, `password` — but **not** `role`.
- **Owner/Admin** can update anyone's `name`, `email`, `password`, `role`.

```jsonc
// Request — any subset
{ "name": "New Name", "email": "new@x.com", "password": "newPass123", "role": "Admin" }

// 200
{ "ok": true, "user": { ... } }
```

Errors: `400` no fields provided · `403` not allowed to update this user / change role · `404` not in workspace · `409` email collision.

### `DELETE /users/:id`

**Requires role:** `Owner` or `Admin`. Removes the membership row (the user account itself is not deleted — they can still be invited to other workspaces). Cannot remove yourself, and cannot remove an `Owner` (demote first).

```jsonc
// 200
{ "ok": true }
```

Errors: `400` trying to remove yourself · `403` target is an Owner · `404` not in workspace.

---

## Customers

All `/customers` endpoints require `Authorization: Bearer <accessToken>`. The `:id` path param accepts either a UUID or a `display_code` (e.g. `CUS-1001`). All rows are workspace-scoped via the JWT and exclude soft-deleted records.

### `GET /customers`

Paginated list with optional filters.

Query params:
- `status` — `Active` | `Inactive` | `Lead`
- `q` — case-insensitive search across `name`, `email`, `display_code`
- `limit` — 1–200, default 50
- `offset` — default 0

```jsonc
// 200
{
  "ok": true,
  "items": [{ "id": "uuid", "displayCode": "CUS-1001", "name": "Acme", "email": "...", "phone": "...", "status": "Active", "industry": "...", "notes": null, "createdAt": "ISO", "updatedAt": "ISO", "deletedAt": null, "workspaceId": "uuid" }],
  "total": 5, "limit": 50, "offset": 0
}
```

### `POST /customers`

**Requires role:** `Owner` / `Admin` / `Agent`. The server generates the `display_code` (e.g. `CUS-1006`).

```jsonc
// Request
{ "name": "New Co", "email": "billing@new.co", "phone": "+1...", "status": "Lead", "industry": "...", "notes": "..." }

// 201
{ "ok": true, "customer": { "id": "uuid", "displayCode": "CUS-1006", ... } }
```

### `GET /customers/:id`

```jsonc
// 200
{ "ok": true, "customer": { ... } }
```

### `PATCH /customers/:id`

**Requires role:** `Owner` / `Admin` / `Agent`. Any subset of the create fields.

```jsonc
// 200
{ "ok": true, "customer": { ... } }
```

### `DELETE /customers/:id`

**Requires role:** `Owner` / `Admin`. Soft delete — sets `deleted_at`.

```jsonc
// 200
{ "ok": true }
```

### `GET /customers/:customerId/contacts`

Convenience endpoint — equivalent to `GET /contacts?customerId=<uuid>&limit=200`. The customer is resolved first (by UUID or `display_code`), so a 404 fires if it doesn't exist before any contact query runs.

```jsonc
// 200
{ "ok": true, "items": [ /* contacts */ ], "total": 2, "limit": 200, "offset": 0 }
```

---

## Contacts

A contact is a person at a customer. Workspace-scoped. Optional `customer_id` — unaffiliated leads have `null`.

### `GET /contacts`

Query params:
- `customerId` — UUID
- `isPrimary` — `true` | `false`
- `q` — searches `firstName`, `lastName`, `email`, `display_code`
- `limit`, `offset` — pagination

```jsonc
// 200
{
  "ok": true,
  "items": [{ "id": "uuid", "displayCode": "CON-1001", "customerId": "uuid", "firstName": "Fabian", "lastName": "Hugo", "email": "...", "phone": "...", "jobTitle": "IT Lead", "location": "Stockholm", "isPrimary": true, "notes": null, "createdAt": "ISO", "updatedAt": "ISO", "deletedAt": null, "workspaceId": "uuid" }],
  "total": 4, "limit": 50, "offset": 0
}
```

### `POST /contacts`

**Requires role:** `Owner` / `Admin` / `Agent`. The server generates the `display_code` (e.g. `CON-1005`). If `customerId` is provided it must be a customer in the same workspace.

```jsonc
// Request
{
  "customerId": "uuid-or-null",
  "firstName": "Jane",
  "lastName": "Doe",
  "email": "jane@x.com",
  "phone": "+1...",
  "jobTitle": "...",
  "location": "...",
  "isPrimary": false,
  "notes": "..."
}

// 201
{ "ok": true, "contact": { "id": "uuid", "displayCode": "CON-1005", ... } }
```

Errors: `404` `customerId` doesn't belong to workspace · `409` email already used by another contact in this workspace.

### `GET /contacts/:id`

```jsonc
{ "ok": true, "contact": { ... } }
```

### `PATCH /contacts/:id`

**Requires role:** `Owner` / `Admin` / `Agent`. Any subset of the create fields. Pass `"customerId": null` to unlink.

### `DELETE /contacts/:id`

**Requires role:** `Owner` / `Admin`. Soft delete. References from `assets.primary_contact_id` and `tickets.requester_contact_id` get nullified (the FKs use `ON DELETE SET NULL`).

```jsonc
{ "ok": true }
```

---

## Assets

All `/assets` endpoints require `Authorization: Bearer <accessToken>`. The `:id` slot accepts UUID or `display_code` (e.g. `AST-1001`). Money is stored as integer **minor units** in `costMinor` (cents for USD/EUR/SEK, yen for JPY, fils for BHD — per ISO 4217). Currency comes from `workspaces.default_currency`.

### `GET /assets`

Query params (all optional):

| Param | Type | Notes |
|---|---|---|
| `lifecycle` | `In Stock` / `In Use` / `In Maintenance` / `Retired` | Filter by lifecycle |
| `category` | `Hardware` / `Software` / `Furniture` / `Equipment` | Filter by category |
| `condition` | `New` / `Good` / `Fair` / `Poor` | Filter by condition |
| `assigneeUserId` | UUID | "My assets" — internal employee |
| `ownerCustomerId` | UUID | "Acme's gear" |
| `primaryContactId` | UUID | "Fabian's responsibility" |
| `warrantyEndsWithinDays` | `0–3650` | Returns assets whose warranty ends on or before today + N days (includes already expired). Use `90` for the standard "expiring soon" surface. |
| `q` | string | Case-insensitive match on `name`, `display_code`, `serial_number` |
| `limit` | `1–200`, default `50` | Pagination |
| `offset` | `≥0`, default `0` | Pagination |

```jsonc
// 200 — each row carries the asset columns plus three nested mini-objects for
// the FK relations (null when not linked). Saves the frontend a round-trip and
// avoids raw UUIDs in the UI.
{
  "ok": true,
  "items": [{
    "id": "uuid",
    "workspaceId": "uuid",
    "displayCode": "AST-1001",
    "name": "MacBook Pro M3",
    "category": "Hardware",
    "lifecycle": "In Use",
    "condition": "Good",
    "assigneeUserId": "uuid-or-null",
    "ownerCustomerId": "uuid-or-null",
    "primaryContactId": "uuid-or-null",
    "assigneeUser":   { "id": "uuid", "name": "Admin User", "email": "admin@…" }, // or null
    "ownerCustomer":  { "id": "uuid", "displayCode": "CUS-1001", "name": "Acme" }, // or null
    "primaryContact": { "id": "uuid", "displayCode": "CON-1001", "firstName": "Fabian", "lastName": "Hugo" }, // or null
    "serialNumber": "C02XL0AAJG5J",
    "location": "HQ — Floor 3",
    "purchaseDate": "2025-02-14",
    "warrantyEnd": "2028-02-14",
    "costMinor": 249900,
    "notes": "AppleCare+ active.",
    "createdAt": "ISO", "updatedAt": "ISO", "deletedAt": null
  }],
  "total": 5, "limit": 50, "offset": 0
}
```

### `POST /assets`

**Requires role:** `Owner` / `Admin` / `Agent`. Server generates the `display_code`.

```jsonc
// Request
{
  "name": "Office Chair",
  "category": "Furniture",
  "lifecycle": "In Stock",
  "condition": "New",
  "assigneeUserId": null,
  "ownerCustomerId": null,
  "primaryContactId": null,
  "serialNumber": "...",
  "location": "Storage",
  "purchaseDate": "2026-05-01",
  "warrantyEnd": "2031-05-01",
  "costMinor": 139500,
  "notes": null
}

// 201
{ "ok": true, "asset": { "id": "uuid", "displayCode": "AST-1006", ... } }
```

**Domain rule applied server-side** — `(lifecycle, assigneeUserId)` are kept consistent:
- Set `assigneeUserId` → lifecycle bumps to `In Use`
- Clear `assigneeUserId` while `In Use` → lifecycle resets to `In Stock`
- Set lifecycle to anything other than `In Use` while touching the assignee → assignee cleared

Errors: `400` bad input · `404` `assigneeUserId` / `ownerCustomerId` / `primaryContactId` not in this workspace · `409` `serialNumber` already used by another active asset in this workspace.

### `GET /assets/:id`

```jsonc
{ "ok": true, "asset": { ... } }
```

### `PATCH /assets/:id`

**Requires role:** `Owner` / `Admin` / `Agent`. Any subset of the create fields. Same domain rule + cross-workspace checks as `POST`.

```jsonc
{ "ok": true, "asset": { ... } }
```

### `DELETE /assets/:id`

**Requires role:** `Owner` / `Admin`. Soft delete (sets `deleted_at`). The `(workspace_id, serial_number)` uniqueness index excludes soft-deleted rows, so a soft-deleted asset frees up its serial number for reuse.

```jsonc
{ "ok": true }
```

---

## Tickets

All `/tickets` endpoints require `Authorization: Bearer <accessToken>`. The `:id` slot accepts UUID or `display_code` (e.g. `TCK-101`). Detail responses also include an array of linked assets via the `ticket_assets` join table.

### `GET /tickets`

Query params (all optional):

| Param | Type |
|---|---|
| `status` | `New` / `In Progress` / `Waiting on Customer` / `Resolved` |
| `priority` | `Low` / `Medium` / `High` |
| `assigneeUserId` | UUID |
| `requesterUserId` | UUID |
| `requesterContactId` | UUID |
| `tag` | string |
| `q` | matches `title`, `display_code` |
| `limit` | 1–200, default 50 |
| `offset` | default 0 |

```jsonc
// 200 — each item carries the ticket columns + three nested mini-objects
{
  "ok": true,
  "items": [{
    "id": "uuid", "workspaceId": "uuid", "displayCode": "TCK-101",
    "title": "Need access to AWS Console",
    "description": "…", "status": "In Progress", "priority": "High",
    "tag": "Access",
    "assigneeUserId": "uuid-or-null",
    "requesterUserId": "uuid-or-null",
    "requesterContactId": "uuid-or-null",
    "requesterLabel": "string-or-null",
    "assigneeUser":     { "id": "uuid", "name": "Admin User", "email": "…" }, // or null
    "requesterUser":    { "id": "uuid", "name": "…", "email": "…" }, // or null
    "requesterContact": { "id": "uuid", "displayCode": "CON-1001", "firstName": "Fabian", "lastName": "Hugo", "customerId": "uuid" }, // or null
    "resolvedAt": null,
    "createdAt": "ISO", "updatedAt": "ISO", "deletedAt": null
  }],
  "total": 5, "limit": 50, "offset": 0
}
```

### `POST /tickets`

**Requires role:** `Owner` / `Admin` / `Agent` / `Requester`. Server generates the `display_code`.

```jsonc
// Request — at most one of requesterUserId / requesterContactId
{
  "title": "Printer broken",
  "description": "…",
  "status": "New",
  "priority": "Medium",
  "tag": "Hardware",
  "assigneeUserId": null,
  "requesterContactId": "uuid",   // OR requesterUserId, not both
  "requesterLabel": null
}

// 201
{ "ok": true, "ticket": { "id": "uuid", "displayCode": "TCK-106", ..., "linkedAssets": [] } }
```

**Domain rules applied server-side:**
- Status `Resolved` ↔ `resolvedAt` — server stamps `resolvedAt = now()` when status flips to Resolved, clears it on the way back
- Requester exclusivity — setting one of `requesterUserId` / `requesterContactId` clears the other

Errors: `400` bad input (incl. both requester FKs set) · `404` assignee/requester FK not in this workspace.

### `GET /tickets/:id`

Detail response includes `linkedAssets` and `watchers`:

```jsonc
{
  "ok": true,
  "ticket": {
    /* same shape as list items, plus: */
    "linkedAssets": [
      { "id": "uuid", "displayCode": "AST-1001", "name": "MacBook Pro M3", "lifecycle": "In Use", "category": "Hardware" }
    ],
    "watchers": [
      { "id": "uuid", "name": "Jane Doe", "email": "jane@projectxyz.local" }
    ]
  }
}
```

### `PATCH /tickets/:id`

**Requires role:** `Owner` / `Admin` / `Agent`. Any subset of the create fields. Same domain rules apply.

### `DELETE /tickets/:id`

**Requires role:** `Owner` / `Admin`. Soft delete.

```jsonc
{ "ok": true }
```

### `POST /tickets/:id/assets/:assetId` &nbsp;·&nbsp; `DELETE /tickets/:id/assets/:assetId`

**Requires role:** `Owner` / `Admin` / `Agent`. Link or unlink an asset from this ticket — operates on the `ticket_assets` junction table. Both `:id` slots accept UUID or `display_code`. POST is idempotent (`ON CONFLICT DO NOTHING`).

```jsonc
{ "ok": true }
```

### `POST /assets/:id/tickets/:ticketId` &nbsp;·&nbsp; `DELETE /assets/:id/tickets/:ticketId`

Mirror of the pair above — same operation called from the asset side. Useful when the asset detail page is the natural place to add a ticket link.

```jsonc
{ "ok": true }
```

Errors (any link/unlink): `404` if the ticket or asset isn't in this workspace.

### `POST /tickets/:id/watchers/:userId` &nbsp;·&nbsp; `DELETE /tickets/:id/watchers/:userId`

**Requires role:** `Owner` / `Admin` / `Agent`. Add or remove a watcher (follower) on this ticket — operates on the `ticket_watchers` junction table.

- `:id` accepts UUID or `display_code` (e.g. `TCK-101`).
- `:userId` must be a **UUID**; there is no user display-code form.
- `POST` is idempotent — re-adding an existing watcher is a no-op (no error, no duplicate event).
- Each successful add/remove emits a `system` event on the ticket activity stream (e.g. `"Added watcher: Jane Doe"`, `"Removed watcher: Jane Doe"`). Duplicate-add and delete-of-nonexistent are silent.
- The user must be a member of the ticket's workspace.

```jsonc
{ "ok": true }
```

Errors: `404` if the ticket isn't in this workspace, or the user isn't a workspace member.

---

## Ticket activity events

The chronological log of everything that's happened on a ticket. Two kinds of events:

- **Manual** (created via `POST`): `email_in`, `email_out`, `note_internal` — agents post replies and internal notes via the reply composer
- **Automatic** (emitted by the server): `state_change`, `assignment`, `system` — server records audit events whenever a `PATCH /tickets/:id` changes `status` or `assigneeUserId`

`actorRole` is derived from `kind`:
- `email_in` → `customer`
- `email_out` / `note_internal` → `agent`
- `state_change` / `assignment` / `system` → `system`

### `GET /tickets/:id/events`

**Bearer.** The ticket is resolved first (by UUID or `display_code`), so a wrong id returns a clean 404 before any event query runs.

Query params: `kind` (any of the 6 kinds), `limit` (1–500, default 100), `offset` (default 0). Sorted ascending by `createdAt`.

```jsonc
// 200 — each item joins the author user (null for inbound emails from unknown senders)
{
  "ok": true,
  "items": [{
    "id": "uuid",
    "ticketId": "uuid",
    "kind": "email_in",
    "actorRole": "customer",
    "authorUserId": null,
    "authorUser": null,
    "authorDisplayName": "Fabian Hugo",
    "body": "Hi IT team, I need…",
    "emailTo": ["IT Support"],
    "emailCc": ["Lisa Andersson"],
    "messageId": "<inbound-msg-id@acme.corp>",   // RFC 5322 Message-ID
    "inReplyTo": null,                            // populated when this event replies to another
    "createdAt": "2026-05-12T11:00:00.000Z"
  }],
  "total": 7, "limit": 100, "offset": 0
}
```

### `POST /tickets/:id/events`

**Requires role:** `Owner` / `Admin` / `Agent`.

Only the three manual kinds are accepted. The server fills in `authorUserId` / `authorDisplayName` from the JWT and derives `actorRole` from `kind`. For `email_out`, the server **auto-generates a Message-ID** if you don't provide one, and **auto-fills `inReplyTo`** with the most recent outbound email's Message-ID on this ticket (matches what a real mail client does).

```jsonc
// Request
{
  "kind": "email_out",            // or "note_internal" / "email_in"
  "body": "Hi Sarah, your IAM account is ready.",
  "emailTo": ["sarah@example.com"],
  "emailCc": ["michael.scott@example.com"],
  // Threading (optional):
  "messageId": "<custom-id@yourdomain>",  // override the auto-generated one
  "inReplyTo": "<parent-msg-id@acme.corp>" // override the parent (e.g. ingesting an inbound reply)
}

// 201
{ "ok": true, "event": { "id": "uuid", "kind": "email_out", "actorRole": "agent", "messageId": "<auto-generated>", "inReplyTo": "<parent>", ...} }
```

Errors: `400` invalid kind (e.g. trying to manually create `state_change`) · `403` caller is not Owner/Admin/Agent · `404` ticket not in workspace.

### Auto-emitted events on `PATCH /tickets/:id`

When you change `status` and/or `assigneeUserId`, the ticket service records audit events alongside the update:

```jsonc
// example after PATCH /tickets/TCK-101 { "status": "Resolved", "assigneeUserId": "<uuid>" }
[
  { "kind": "state_change", "actorRole": "system", "body": "Status: New → Resolved", ... },
  { "kind": "assignment",   "actorRole": "system", "body": "Assignee: Unassigned → (new assignee)", ... }
]
```

The author is whoever made the PATCH (read from the JWT). Event emission is best-effort — if it fails, the ticket update still commits and the failure is logged.

---

## Routing Rules

Declarative rules that auto-route tickets when they're created. First matching rule (lowest `priority` value) wins. The engine evaluates on `POST /tickets` and emits a `system` event on the ticket if a rule matches.

**Condition fields**: `tag` · `subject` (matches `ticket.title`) · `senderEmail` · `senderDomain` · `requesterContactId` · `requesterCustomerId`

**Operators**: `equals` · `contains` · `startsWith` · `endsWith` · `in` (the value must be a `string[]`)

**Actions** (any subset):
- `assigneeUserId: uuid | null` — set the assignee
- `addTag: string` — set the ticket's tag if it's currently null
- `setPriority: 'Low' | 'Medium' | 'High'`
- `setStatus: 'New' | 'In Progress' | 'Waiting on Customer' | 'Resolved'`
- `addNote: string` — emit an extra system event with this body

### `GET /routing-rules`

```jsonc
{
  "ok": true,
  "items": [{
    "id": "uuid", "workspaceId": "uuid", "name": "Hardware → IT",
    "enabled": true, "priority": 10,
    "conditions": [{ "field": "tag", "operator": "equals", "value": "Hardware" }],
    "actions": { "assigneeUserId": "uuid", "setPriority": "Medium" },
    "createdAt": "ISO", "updatedAt": "ISO", "deletedAt": null
  }],
  "total": 1
}
```

### `POST /routing-rules`

**Requires role:** `Owner` / `Admin`.

```jsonc
{
  "name": "Acme tickets → Fabian",
  "enabled": true,
  "priority": 20,
  "conditions": [
    { "field": "senderDomain", "operator": "equals", "value": "acme.corp" }
  ],
  "actions": {
    "assigneeUserId": "<fabian-or-admin-uuid>",
    "addTag": "Acme",
    "setPriority": "High"
  }
}
```

Errors: `400` invalid input (no conditions / no actions / unknown field-operator combo).

### `GET /routing-rules/:id` · `PATCH /routing-rules/:id` · `DELETE /routing-rules/:id`

Standard CRUD. PATCH accepts any subset of the create fields. DELETE is soft (`deleted_at`).

### How matching works on ticket create

After `POST /tickets` inserts the row:

1. Build context from the new ticket: `{ tag, subject, senderEmail, senderDomain, requesterContactId, requesterCustomerId }`. `senderEmail` comes from the linked `contact.email` if `requesterContactId` is set; `senderDomain` is everything after the `@`.
2. Load all enabled rules for this workspace, ordered by `priority ASC, createdAt ASC`.
3. For each rule, check that **every** condition matches. First rule whose conditions all match wins.
4. Apply the matched rule's actions to the ticket (UPDATE), emit one `system` event `"Auto-routed by rule: <name>"`, and one extra `system` event per `addNote`.

Routing failures (DB blip, malformed rule JSON) are caught and logged — they never prevent the ticket from being created.

---

## Orders

All `/orders` endpoints require `Authorization: Bearer <accessToken>`. The `:id` slot accepts UUID or `display_code` (e.g. `ORD-101`). Money is integer **minor units** in `amountMinor` / `unitAmountMinor` / `totalMinor` — the smallest indivisible unit of the workspace's currency per ISO 4217 (cents for USD/EUR/SEK, yen for JPY, fils for BHD). Currency comes from `workspaces.default_currency`. Each order carries an `items[]` array (line items) — totals are derived server-side from the items, never trusted from the client.

**Order types and item kinds are paired**: a `Time-based` order accepts only `time` items, an `Article-based` order only `article` items. The server rejects mismatches with a 400.

**State machine** (enforced server-side; no PATCH on `status`):
- `Draft` → `Sent` via `POST /orders/:id/send` (stamps `sent_at`)
- `Sent` → `Paid` via `POST /orders/:id/paid` (stamps `paid_at`)
- `Draft` or `Sent` → `Cancelled` via `POST /orders/:id/cancel` (stamps `cancelled_at`)
- `Paid` is terminal — no edits, no delete (cancel a future order instead)
- `Cancelled` is also closed to edits

When an order is created, updated, transitioned, or deleted while attached to a ticket, the server emits a `system` event on that ticket's activity stream (best-effort — failure is logged, never rolls back the order operation).

### `GET /orders`

Query params (all optional):

| Param | Type | Notes |
|---|---|---|
| `status` | `Draft` / `Sent` / `Paid` / `Cancelled` | |
| `type` | `Time-based` / `Article-based` | |
| `customerId` | UUID | |
| `ticketId` | UUID | |
| `contractId` | UUID | |
| `q` | string | Case-insensitive match on `display_code`, `description` |
| `limit` | 1–200, default 50 | |
| `offset` | ≥0, default 0 | |

```jsonc
// 200 — each row carries the order columns plus three FK mini-objects (null when not linked)
// and the full `items[]` array (bulk-loaded in one query for the whole page).
{
  "ok": true,
  "items": [{
    "id": "uuid",
    "workspaceId": "uuid",
    "displayCode": "ORD-101",
    "customerId": "uuid-or-null",
    "ticketId": "uuid-or-null",
    "contractId": "uuid-or-null",
    "customer": { "id": "uuid", "displayCode": "CUS-1001", "name": "Acme" }, // or null
    "ticket":   { "id": "uuid", "displayCode": "TCK-102", "title": "AWS access setup" }, // or null
    "contract": { "id": "uuid", "displayCode": "CTR-2001", "name": "Acme Support 2026" }, // or null
    "type": "Time-based",
    "status": "Sent",
    "amountMinor": 15000,
    "description": "2 hours of IT Support for AWS access setup",
    "items": [
      {
        "id": "uuid", "orderId": "uuid",
        "kind": "time",
        "description": "IT Support — AWS access setup",
        "quantity": "2.00",            // numeric(10,2) on the wire as a string
        "unitAmountMinor": 7500,
        "totalMinor": 15000,
        "sortOrder": 0
      }
    ],
    "sentAt": "2026-05-12T11:00:00Z",
    "paidAt": null,
    "cancelledAt": null,
    "createdAt": "ISO", "updatedAt": "ISO", "deletedAt": null
  }],
  "total": 3, "limit": 50, "offset": 0
}
```

### `POST /orders`

**Requires role:** `Owner` / `Admin` / `Agent`. Server generates the `display_code`. At least one line item is required.

```jsonc
// Request
{
  "type": "Article-based",
  "customerId": "uuid-or-null",
  "ticketId": "uuid-or-null",
  "contractId": "uuid-or-null",
  "description": "New MacBook Pro charger and accessories",
  "items": [
    {
      "kind": "article",                 // must match the order's type
      "description": "MacBook Pro 140W USB-C charger",
      "quantity": "1.00",                // number or numeric string; up to 2 decimals; must be > 0
      "unitAmountMinor": 9900
    },
    {
      "kind": "article",
      "description": "USB-C hub + accessories bundle",
      "quantity": 2,
      "unitAmountMinor": 55050
    }
  ]
}

// 201 — server pre-computes each `totalMinor` and the order `amountMinor`. Insert is atomic
// (order + items in one transaction); a `system` event is emitted on `ticketId` if attached.
{ "ok": true, "order": { "id": "uuid", "displayCode": "ORD-104", "status": "Draft", ... } }
```

Errors: `400` invalid input (no items / item kind ≠ order type / negative qty) · `404` `customerId` / `ticketId` / `contractId` not in this workspace.

### `GET /orders/:id`

```jsonc
{ "ok": true, "order": { /* same shape as list items */ } }
```

### `PATCH /orders/:id`

**Requires role:** `Owner` / `Admin` / `Agent`. Any subset of the header fields. If `items[]` is provided, **the line items are atomically replaced** (delete all + insert) and `amountMinor` is recomputed — same shape as POST, same item-kind ↔ order-type guard. `status` is **not** patchable here — use the transition endpoints.

```jsonc
// Request — examples
{ "description": "Updated description" }                 // header only
{ "items": [ {...}, {...} ] }                            // replace line items + recompute total
{ "type": "Article-based", "items": [ {...} ] }          // change type + items together (or kinds must match)
{ "ticketId": "uuid" }                                   // re-attach to a ticket (emits a detach + attach event pair)

// 200
{ "ok": true, "order": { ... } }
```

Errors: `400` bad input · `400` order is `Paid` or `Cancelled` (immutable) · `400` item kind doesn't match order type · `404` FK not in workspace.

### `DELETE /orders/:id`

**Requires role:** `Owner` / `Admin`. Soft delete (`deleted_at`). Refuses to delete a `Paid` order — cancel a future replacement instead.

```jsonc
{ "ok": true }
```

### `POST /orders/:id/send` · `POST /orders/:id/paid` · `POST /orders/:id/cancel`

**Requires role:** `Owner` / `Admin` / `Agent`. Empty body. Each call is guarded against illegal source statuses (see state machine at the top of this section) and stamps the corresponding timestamp. A `system` event lands on the attached ticket (if any) describing the transition.

```jsonc
// 200 — same shape as GET, with updated status + stamp
{ "ok": true, "order": { "status": "Sent", "sentAt": "2026-05-15T13:00:00Z", ... } }
```

Errors: `400` illegal transition (e.g. `paid` on a `Draft` order) · `404` order not in workspace.

---

### JWT payload shape

```jsonc
{
  "sub": "<user-uuid>",
  "email": "...",
  "name": "...",
  "wid": "<workspace-uuid>",
  "wrole": "Owner | Admin | Agent | Requester",
  "iat": 1715800000,
  "exp": 1715800900
}
```

Default TTLs (overridable in `.env`):
- Access token: **15 minutes** (`JWT_ACCESS_TTL`)
- Refresh cookie: **30 days** (`JWT_REFRESH_TTL`)

---

# Pending endpoints

Signatures only — full bodies / response shapes get added when the step ships. All endpoints require `Authorization: Bearer <accessToken>` unless noted, and are workspace-scoped via the JWT.

## Step 5c — SLAs

| Method | Path             | Purpose                                                  |
|--------|------------------|----------------------------------------------------------|
| GET    | `/slas`          | List SLA templates in workspace                          |
| POST   | `/slas`          | Create an SLA template                                    |
| PATCH  | `/slas/:id`      | Update                                                    |
| DELETE | `/slas/:id`      | Soft delete                                               |

(Per-ticket SLA state is exposed inline on `/tickets/:id`.)

## Step 5–6 — Attachments

Polymorphic; stored on B2. Upload is a two-step flow: client requests a presigned URL, uploads directly to B2, then registers the metadata.

| Method | Path                       | Purpose                                                    |
|--------|----------------------------|------------------------------------------------------------|
| POST   | `/attachments/upload-url`  | Get a presigned B2 PUT URL for the client to upload to    |
| POST   | `/attachments`             | Register metadata after the B2 upload completes           |
| GET    | `/attachments/:id`         | Get one (returns a signed download URL)                   |
| DELETE | `/attachments/:id`         | Soft delete                                               |

Attachments belong to a parent via `owner_table` + `owner_id` — typically `ticket` or `contract`, but any of the enum members work.

## Step 7 — Contracts

| Method | Path                                | Purpose                                                                 |
|--------|-------------------------------------|-------------------------------------------------------------------------|
| GET    | `/contracts`                        | List + filters (`?status=&type=&customerId=`)                           |
| POST   | `/contracts`                        | Create                                                                  |
| GET    | `/contracts/:id`                    | Detail incl. recent events                                              |
| PATCH  | `/contracts/:id`                    | Partial update                                                          |
| DELETE | `/contracts/:id`                    | Soft delete                                                             |
| POST   | `/contracts/:id/trigger-billing`    | Atomically generate an `Order` + closed `Ticket` + `contract_events` row; advances `next_billing_date` |
| POST   | `/contracts/:id/terminate`          | Mark `Terminated`, set `terminated_at`                                  |
| GET    | `/contracts/:id/events`             | Contract history timeline                                               |

---

## Postman setup

1. **Create environment** `ProjectXYZ Local` with:
   - `baseUrl` = `http://localhost:4000/api/v1`
   - `accessToken` = (leave blank — login script fills it)

2. **Enable cookie jar** for `localhost:4000` (Postman → cookies icon → add `localhost:4000`). Without this, refresh/logout can't see the `rt` cookie.

3. **`Login` request** — `POST {{baseUrl}}/auth/login`, raw-JSON body:
   ```json
   { "email": "admin@projectxyz.local", "password": "changeme123" }
   ```
   On the **Tests** tab:
   ```js
   const data = pm.response.json();
   pm.environment.set('accessToken', data.accessToken);
   ```

4. **Protected requests** — Authorization → type `Bearer Token`, value `{{accessToken}}`.

5. **`Refresh`/`Logout`** — no auth header; just rely on the cookie jar. Add the same Tests-tab snippet to Refresh so it keeps `accessToken` fresh after rotation.

---

## curl smoke test

```bash
# 1. Login (writes the rt cookie to ./cookies.txt)
curl -s -X POST http://localhost:4000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -c cookies.txt \
  -d '{"email":"admin@projectxyz.local","password":"changeme123"}' | jq

# 2. Copy the accessToken from the response above into:
TOKEN="eyJhbGci..."

# 3. Me
curl -s http://localhost:4000/api/v1/auth/me \
  -H "Authorization: Bearer $TOKEN" | jq

# 4. Refresh (uses cookie jar; new cookie is written back)
curl -s -X POST http://localhost:4000/api/v1/auth/refresh \
  -b cookies.txt -c cookies.txt | jq

# 5. Logout
curl -s -X POST http://localhost:4000/api/v1/auth/logout \
  -b cookies.txt -c cookies.txt | jq
```
