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:
@@ -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.
|
||||
Reference in New Issue
Block a user