Add REST API for quotes, shots, assets and tasks

Adds a key-authenticated REST API at /api/v1 so another system can create
and manage quotes without going through the UI.

Endpoints cover quotes (list, create, read, update, delete, recalculate),
shots, assets and line items, plus read-only clients and rate cards.
POST /api/v1/quotes builds a whole priced quote -- client, shots, assets
and tasks -- in a single transaction. Routes are also mounted under
/api/v1/jobs, since a quote and a job are the same record here.

Keys are stored as SHA-256 with a display prefix, so the plaintext is
shown once at creation and is not recoverable. They carry read/write
scopes, can be revoked (effective on the next request), and record when
they were last used. Minting and revoking live in Settings and are
restricted to signed-in admins.

/api/v1 is exempt from the session proxy because it authenticates with a
bearer key rather than a cookie -- without this, every API call was
redirected to /login as a 307. It is not unprotected: each route handler
requires a live, unrevoked key with the right scope.

Shot numbering and shot-code construction move into lib/shot-codes.ts so
the UI and the API cannot drift. This also fixes numbering restarting at
0010 when a scene was entered in a different case to the stored one: the
lookup upper-cased the scene but the stored value was left as typed.

Unspecified currency and markup/tax/discount on an API-created quote fall
back to the studio's configured quote defaults.

Decimal fields are serialised as strings to preserve the exact precision
the app's decimal arithmetic maintains.

Note: this needs `prisma migrate deploy` for the new api_keys table.
Settings will error for admins until that runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-11-11 09:10:45 +02:00
parent 2ed7ae46a4
commit 8a93a18bb9
34 changed files with 2946 additions and 19 deletions
+494
View File
@@ -0,0 +1,494 @@
# xBid API — v1
A REST API for creating and managing quotes (jobs), shots, assets and tasks
programmatically, so another system can file a priced quote into xBid without
anyone opening the UI.
Base URL: `https://xbid.twotalesvfx.com/api/v1` (locally, `http://localhost:3000/api/v1`)
> This is the **inbound** API — other systems calling xBid.
> For the outbound direction, where xBid pulls shots from the review app, see
> [`GET_SHOTS.md`](GET_SHOTS.md).
---
## Contents
- [Authentication](#authentication)
- [Managing keys](#managing-keys)
- [Conventions](#conventions)
- [Errors](#errors)
- [Quick start](#quick-start)
- [Endpoints](#endpoints)
- [Quotes](#quotes)
- [Shots](#shots)
- [Assets](#assets)
- [Tasks (line items)](#tasks-line-items)
- [Reference data](#reference-data)
- [Pricing](#pricing)
- [Shot codes](#shot-codes)
---
## Authentication
Every request needs an API key:
```
Authorization: Bearer xbid_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
`X-API-Key: <key>` is accepted as an alternative for clients that cannot set
an `Authorization` header.
Keys carry scopes:
| Scope | Grants |
|---------|--------------------------------------------------------|
| `read` | All `GET` endpoints |
| `write` | `POST`, `PATCH` and `DELETE` — creating and changing data |
A read-only key sent to a write endpoint gets `403 forbidden`, not `401`.
Check a key works, and see what it may do:
```bash
curl -s https://xbid.twotalesvfx.com/api/v1 \
-H "Authorization: Bearer $XBID_API_KEY"
```
That endpoint echoes back the key's name and scopes, and lists every available
route — it is the first thing to call when wiring up an integration.
### Managing keys
Keys are created in the app: **Settings → API Keys → New Key**. Only signed-in
**admin** users can mint, revoke or delete them.
A new key's plaintext is shown **once**, at creation. Only a SHA-256 hash and a
short display prefix are stored, so a lost key cannot be recovered — revoke it
and issue another. Revoking takes effect on the very next request.
Each key records when it was last used (to the nearest minute), so an unused
integration is easy to spot before you revoke it.
---
## Conventions
**Response envelope.** Success is `{ "data": … }`, with `{ "meta": … }` added on
list endpoints. Failure is `{ "error": { … } }`. The two never mix.
```json
{ "data": { "id": "clx…", "projectTitle": "Hero Sequence" } }
```
```json
{
"data": [ … ],
"meta": { "total": 42, "limit": 25, "offset": 0, "returned": 25 }
}
```
**Decimals are strings.** Money, hours, rates, multipliers and percentages come
back as JSON strings (`"10000.0000"`), not numbers. xBid prices everything with
exact decimal arithmetic, and sending floats over the wire would reintroduce the
drift that avoids. Parse them with a decimal library, not `parseFloat`, if you
intend to do arithmetic.
On input, numbers and numeric strings are both accepted.
**Quotes are addressable by code.** Anywhere a quote id appears in a path, its
unique `projectCode` works too:
```
GET /api/v1/quotes/clx8f3a… # by id
GET /api/v1/quotes/HERO-SEQUENCE # by projectCode
```
**Quotes and jobs are the same thing.** A "quote" and a "job" are one record in
xBid. Every `/api/v1/quotes/…` route is also mounted at `/api/v1/jobs/…`; use
whichever name your side calls it.
**Dates** accept `2026-03-01`, a full ISO 8601 timestamp, or epoch
milliseconds, and are always returned as ISO 8601.
**One or many.** Endpoints that add shots, assets or tasks accept either a
single object or an array of them, up to 500 per request. The response mirrors
what you sent: an object for an object, an array for an array.
**PATCH is partial.** Omitted fields keep their stored values — a `PATCH` never
clears a field by leaving it out. Where clearing is meaningful (`expiryDate`,
`manualSubtotal`, …), send an explicit `null`.
**Nothing is cached.** All responses are `Cache-Control: no-store`.
---
## Errors
```json
{
"error": {
"code": "validation_failed",
"message": "Request body failed validation.",
"details": { "projectTitle": ["projectTitle is required"] }
}
}
```
| Status | `code` | Meaning |
|--------|---------------------|-----------------------------------------------------------|
| 400 | `invalid_request` | Malformed JSON, or a reference to something that does not exist |
| 401 | `unauthorized` | Missing, unknown, revoked or expired key |
| 403 | `forbidden` | Valid key, but missing the required scope |
| 404 | `not_found` | No such quote, shot, asset or task |
| 405 | — | Method not supported on that route |
| 409 | `conflict` | Unique clash — a duplicate `projectCode` or shot code |
| 422 | `validation_failed` | Body parsed, but failed validation; see `details` |
| 500 | `internal_error` | Something broke; details are logged server-side, not returned |
`details` on a validation failure maps each field path to its messages.
---
## Quick start
Create a complete, priced quote in one request — client, shots, assets and
tasks included:
```bash
curl -X POST https://xbid.twotalesvfx.com/api/v1/quotes \
-H "Authorization: Bearer $XBID_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"projectTitle": "Hero Sequence VFX",
"showId": "HERO",
"client": { "companyName": "Big Screen Pictures", "email": "accounts@bigscreen.example" },
"currency": "ZAR",
"shots": [
{
"scene": "010",
"description": "Hero wide establishing shot",
"durationSeconds": 5,
"complexityMultiplier": 1.5,
"lineItems": [
{ "taskName": "Compositing", "rateCardName": "Senior Compositor", "quantityHours": 24 },
{ "taskName": "Roto", "dailyRate": 4000, "numDays": 2, "numArtists": 2 }
]
},
{ "scene": "010", "description": "Hero close-up" },
{ "scene": "020", "description": "Crowd extension", "lineItems": [
{ "taskName": "CG Crowd", "rateCardName": "FX TD", "numDays": 5 }
]}
],
"assets": [
{
"assetName": "Hero Spaceship",
"assetType": "Vehicle",
"lineItems": [
{ "taskName": "Modelling", "rateCardName": "Modeller", "numDays": 10 },
{ "taskName": "Texturing", "rateCardName": "Texture Artist", "numDays": 6 }
]
}
]
}'
```
The whole payload is applied in **one transaction** — if any part is rejected,
nothing is created. The response is the full quote graph, with every task
priced and every shot coded (`HERO_010_0010`, `HERO_010_0020`,
`HERO_020_0010`).
Then open it in the UI at `/jobs/{id}`, or export it:
- PDF: `GET /api/pdf/{id}`
- CSV shotlist: `GET /api/csv/shots/{id}` (add `?detail=tasks` for a full cost breakdown)
Those two export routes use the app's session login, not an API key.
---
## Endpoints
### Quotes
#### `GET /api/v1/quotes`
List quotes, newest first.
| Query | Default | Notes |
|------------|---------|------------------------------------------|
| `limit` | 25 | 1–100 |
| `offset` | 0 | For paging |
| `status` | — | `DRAFT` `SENT` `APPROVED` `REJECTED` `ARCHIVED` |
| `clientId` | — | Filter to one client |
| `search` | — | Matches project title or code, case-insensitive |
Each row includes its client and a `_count` of shots, assets, supervisions and
previses. `meta.total` is the count before paging.
#### `POST /api/v1/quotes`
Create a quote. Requires `projectTitle`, plus either `clientId` or `client`.
| Field | Default |
|---|---|
| `projectTitle` | **required** |
| `clientId` *or* `client` | **required** — see below |
| `projectCode` | derived from the title (and `showId`), with `-2`, `-3`… appended to stay unique |
| `showId` | derived from `projectCode`; max 10 chars, letters/numbers/underscores |
| `jobType` | `STANDARD`; `EPISODIC` puts the episode in shot codes |
| `status` | `DRAFT` |
| `currency` | the studio's default currency (Settings), else `ZAR` |
| `issueDate` | today |
| `markupPercent`, `taxPercent`, `discountPercent` | the studio's quote defaults (Settings), else `0` |
| `productionFeePercent`, `pullManagementFeePercent` | `0` |
| `expiryDate`, `startDate`, `estimatedCompletionDate` | unset |
| `description`, `internalNotes`, `termsAndConditions`, `customHeaderHtml`, `customFooterHtml` | unset |
| `shots`, `assets` | none — see [Shots](#shots) and [Assets](#assets) for the item shape |
**Attaching a client.** Either pass a `clientId`, or pass a `client` object and
xBid will match an existing client on `companyName` (case-insensitive) and
create one only if there is no match. This is the only place the API writes to
clients.
```json
{ "client": { "companyName": "Big Screen Pictures", "contactPerson": "Dana Okafor" } }
```
A caller-supplied `projectCode` that is already taken returns `409 conflict`;
omit it and xBid picks a free one.
Returns `201` with the full quote graph.
#### `GET /api/v1/quotes/{idOrCode}`
The full quote: client, shots, assets, supervisions, previses, every task with
its rate card, and the payment schedule with its milestones.
#### `PATCH /api/v1/quotes/{idOrCode}`
Update quote-level fields — any field from `POST` except `shots`, `assets` and
`client`. Shots and assets have their own endpoints.
```bash
curl -X PATCH .../api/v1/quotes/HERO-SEQUENCE \
-H "Authorization: Bearer $XBID_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "SENT", "expiryDate": "2026-04-30" }'
```
#### `DELETE /api/v1/quotes/{idOrCode}`
Deletes the quote and everything that cascades from it — shots, assets, tasks,
supervisions, previses, the payment schedule and **its invoices**. There is no
undo.
#### `POST /api/v1/quotes/{idOrCode}/recalculate`
Recompute and re-store every task total on the quote, returning the refreshed
quote.
The write endpoints already price each task as it is created, so this is for
repairing rows that arrived another way: a restored backup, a direct SQL edit,
or a `complexityMultiplier` changed without touching the tasks underneath it.
---
### Shots
A shot belongs to a quote and holds tasks.
#### `GET /api/v1/quotes/{idOrCode}/shots`
The quote's shots in display order, each with its tasks.
#### `POST /api/v1/quotes/{idOrCode}/shots`
Add one shot, or an array of them.
| Field | Default |
|---|---|
| `scene` | `"SC"`; upper-cased |
| `shotCode` | generated — see [Shot codes](#shot-codes) |
| `episode` | unset; used in codes on `EPISODIC` quotes. Upper-cased |
| `shotGroup` | unset — a grouping label for non-episodic quotes |
| `description`, `notes` | unset |
| `durationSeconds` | unset |
| `complexityMultiplier` | `1` — 0.1 to 10, multiplies every task on the shot |
| `lineItems` (alias `tasks`) | none |
```bash
curl -X POST .../api/v1/quotes/HERO-SEQUENCE/shots \
-H "Authorization: Bearer $XBID_API_KEY" \
-H "Content-Type: application/json" \
-d '[
{ "scene": "030", "description": "Matte painting", "complexityMultiplier": 2 },
{ "scene": "030", "description": "Set extension", "lineItems": [
{ "taskName": "Paint", "rateCardName": "Senior Compositor", "quantityHours": 16 }
]}
]'
```
Returns `201`. Codes continue from the highest number already used in that
scene, so adding shots to an existing quote never collides.
#### `GET` · `PATCH` · `DELETE /api/v1/shots/{id}`
Fetch, update or delete one shot. `PATCH` also accepts `shotNumber` and
`sortOrder`.
An existing `shotCode` is **left alone** unless you send a new one — changing a
shot's scene does not silently renumber it, because codes are usually shared
with other systems by then.
Deleting a shot deletes its tasks.
---
### Assets
An asset belongs to a quote and holds tasks. Unlike shots, assets have no codes
or numbering.
#### `GET /api/v1/quotes/{idOrCode}/assets`
#### `POST /api/v1/quotes/{idOrCode}/assets`
One asset or an array.
| Field | Default |
|---|---|
| `assetName` | **required** |
| `assetType` | unset — free text, e.g. `Vehicle`, `Character`, `Environment` |
| `description`, `notes` | unset |
| `complexityMultiplier` | `1` — 0.1 to 10 |
| `lineItems` (alias `tasks`) | none |
```bash
curl -X POST .../api/v1/quotes/HERO-SEQUENCE/assets \
-H "Authorization: Bearer $XBID_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"assetName": "Villain Mech",
"assetType": "Character",
"complexityMultiplier": 1.25,
"lineItems": [{ "taskName": "Rigging", "rateCardName": "Rigger", "numDays": 8 }]
}'
```
#### `GET` · `PATCH` · `DELETE /api/v1/assets/{id}`
---
### Tasks (line items)
A task is one costed row under a shot or an asset.
#### `GET` · `POST /api/v1/shots/{id}/line-items`
#### `GET` · `POST /api/v1/assets/{id}/line-items`
`POST` takes one task or an array, appended after any existing ones.
| Field | Default |
|---|---|
| `taskName` | **required** |
| `artistRole` | unset |
| `rateCardId` *or* `rateCardName` | unset — `rateCardName` matches case-insensitively |
| `dailyRate` | the rate card's `defaultDailyRate`, else `0` |
| `hoursPerDay` | the rate card's `hoursPerDay`, else `8` (1–24) |
| `quantityHours` | `0` |
| `numDays` | `0` |
| `numArtists` | `1` |
| `manualHourlyRate` | unset — overrides the derived hourly rate |
| `manualSubtotal` | unset — overrides the computed subtotal outright |
| `markupPercent`, `discountPercent`, `taxPercent` | `0` |
| `notes` | unset |
An unknown `rateCardId` or `rateCardName` is rejected with `400`, rather than
quietly costing the task at zero.
#### `GET` · `PATCH` · `DELETE /api/v1/line-items/{id}`
`PATCH` reprices the task from the merged result of its stored values and your
changes, using its parent's complexity multiplier. Send `null` for
`manualHourlyRate` or `manualSubtotal` to drop an override and go back to the
derived figure.
---
### Reference data
Both are read-only.
#### `GET /api/v1/clients`
Find a `clientId` before creating a quote. Supports `limit`, `offset` and
`search` (company name or contact person).
#### `GET /api/v1/rate-cards`
List rate cards to quote against. Active cards only by default; add
`includeInactive=1` for all. Supports `search` (name or department).
---
## Pricing
Every task is priced on the server as it is written, so stored totals always
match what the PDF and CSV exports show. The arithmetic is exact decimal, never
floating point.
**Hourly rate** — `manualHourlyRate` if given, otherwise `dailyRate ÷ hoursPerDay`.
**Subtotal** — the first of these that applies:
1. `manualSubtotal`, used as-is (the complexity multiplier does **not** apply)
2. `numDays > 0` → `numDays × numArtists × dailyRate × complexity`
3. otherwise → `quantityHours × hourlyRate × numArtists × complexity`
So a task is costed **by days** as soon as `numDays` is non-zero, even if
`quantityHours` is also set. Send one or the other.
**Adjustments** apply in a fixed order, each on the running figure:
```
subtotal
+ markupPercent → markupAmount
− discountPercent → discountAmount (on the marked-up amount)
+ taxPercent → taxAmount (on the post-discount amount)
= total
```
A task at 100.00 with 10% markup, 10% discount and 15% tax totals **113.85**:
`100 → 110 → 99 → 113.85`. Note the discount is 11.00, not 10.00, because it
applies after markup.
The quote-level `markupPercent`, `taxPercent`, `discountPercent`,
`productionFeePercent` and `pullManagementFeePercent` are applied on top when
the quote is totalled for presentation; they do not change stored task totals.
---
## Shot codes
Codes are generated per scene, continuing from the highest number already used,
and step by 10 so shots can be inserted between existing ones later:
```
STANDARD {showId}_{scene}_{number} HERO_010_0010
EPISODIC {showId}_{episode}_{scene}_{number} HERO_103_010_0010
```
Numbering is scoped **per scene**, and per episode as well on episodic quotes —
so scene `010` and scene `020` each start at `0010`, and episode `103` numbers
independently of episode `104`.
`showId`, `scene` and `episode` are upper-cased, and the number is padded to 4
digits.
Supply an explicit `shotCode` to preserve codes from another system; it is
upper-cased and must be unique within the quote, or you get `409 conflict`.
The UI generates codes the same way, through the same shared module, so shots
added either route stay consistent.