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.
|
||||||
@@ -14,6 +14,7 @@ A professional full-stack VFX quoting application built with **Next.js 15**, **T
|
|||||||
- **Rate Cards** — studio rate card library organized by department
|
- **Rate Cards** — studio rate card library organized by department
|
||||||
- **PDF Export** — professional quote PDF with front page + detailed appendix
|
- **PDF Export** — professional quote PDF with front page + detailed appendix
|
||||||
- **Decimal Precision** — all financials use `decimal.js` (no floating-point drift)
|
- **Decimal Precision** — all financials use `decimal.js` (no floating-point drift)
|
||||||
|
- **REST API** - create and manage quotes, shots, assets and tasks programmatically ([API.md](API.md))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -130,6 +131,28 @@ Print to PDF via browser (Ctrl+P ? Save as PDF).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## REST API
|
||||||
|
|
||||||
|
xBid exposes a REST API at `/api/v1` so another system can create and manage
|
||||||
|
quotes, shots, assets and tasks without anyone opening the UI.
|
||||||
|
|
||||||
|
Requests authenticate with an API key, created under **Settings -> API Keys**
|
||||||
|
(admins only):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:3000/api/v1 \
|
||||||
|
-H "Authorization: Bearer $XBID_API_KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
That endpoint lists every available route and confirms what the key may do.
|
||||||
|
See **[API.md](API.md)** for the full reference: endpoints, pricing rules, shot
|
||||||
|
code generation and error codes.
|
||||||
|
|
||||||
|
Note: `GET_SHOTS.md` documents the opposite direction - the review app's API
|
||||||
|
that xBid pulls shots *from*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "api_keys" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"prefix" TEXT NOT NULL,
|
||||||
|
"hash" TEXT NOT NULL,
|
||||||
|
"scopes" TEXT[] DEFAULT ARRAY['read', 'write']::TEXT[],
|
||||||
|
"lastUsedAt" TIMESTAMP(3),
|
||||||
|
"expiresAt" TIMESTAMP(3),
|
||||||
|
"revokedAt" TIMESTAMP(3),
|
||||||
|
"createdById" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "api_keys_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "api_keys_prefix_key" ON "api_keys"("prefix");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "api_keys_hash_key" ON "api_keys"("hash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "api_keys_revokedAt_idx" ON "api_keys"("revokedAt");
|
||||||
@@ -624,3 +624,24 @@ model InvoicePayment {
|
|||||||
|
|
||||||
@@map("invoice_payments")
|
@@map("invoice_payments")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── API Keys ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
model ApiKey {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
/// First 16 chars of the key ("xbid_" + 11 random) — safe to display.
|
||||||
|
prefix String @unique
|
||||||
|
/// SHA-256 of the full key. The key itself is never stored.
|
||||||
|
hash String @unique
|
||||||
|
scopes String[] @default(["read", "write"])
|
||||||
|
lastUsedAt DateTime?
|
||||||
|
expiresAt DateTime?
|
||||||
|
revokedAt DateTime?
|
||||||
|
createdById String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([revokedAt])
|
||||||
|
@@map("api_keys")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { generateApiKey } from "@/lib/api-keys";
|
||||||
|
import { apiKeyCreateSchema } from "@/lib/api-validations";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API keys grant full programmatic access to quotes, so minting and revoking
|
||||||
|
* them is restricted to signed-in admins — unlike the rest of this app's
|
||||||
|
* actions, which are reachable by anyone who can load the page.
|
||||||
|
*/
|
||||||
|
async function requireAdmin(): Promise<string> {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
throw new Error("You must be signed in to manage API keys.");
|
||||||
|
}
|
||||||
|
if (session.user.role !== "ADMIN") {
|
||||||
|
throw new Error("Only admins can manage API keys.");
|
||||||
|
}
|
||||||
|
return session.user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything about each key except the key itself, which is unrecoverable. */
|
||||||
|
export async function listApiKeys() {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const keys = await prisma.apiKey.findMany({
|
||||||
|
orderBy: [{ revokedAt: "asc" }, { createdAt: "desc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
prefix: true,
|
||||||
|
scopes: true,
|
||||||
|
lastUsedAt: true,
|
||||||
|
expiresAt: true,
|
||||||
|
revokedAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return JSON.parse(JSON.stringify(keys));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a key. The plaintext is returned once here and never stored — only its
|
||||||
|
* SHA-256 and display prefix are persisted, so a lost key must be replaced
|
||||||
|
* rather than looked up.
|
||||||
|
*/
|
||||||
|
export async function createApiKey(data: unknown) {
|
||||||
|
const userId = await requireAdmin();
|
||||||
|
const validated = apiKeyCreateSchema.parse(data);
|
||||||
|
const { key, prefix, hash } = generateApiKey();
|
||||||
|
|
||||||
|
const record = await prisma.apiKey.create({
|
||||||
|
data: {
|
||||||
|
name: validated.name,
|
||||||
|
prefix,
|
||||||
|
hash,
|
||||||
|
scopes: validated.scopes,
|
||||||
|
expiresAt: validated.expiresAt ?? null,
|
||||||
|
createdById: userId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
prefix: true,
|
||||||
|
scopes: true,
|
||||||
|
expiresAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/settings");
|
||||||
|
return { success: true, key, record: JSON.parse(JSON.stringify(record)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a key. Kept as a row rather than deleted so the audit trail — who it
|
||||||
|
* was for, when it was last used — survives.
|
||||||
|
*/
|
||||||
|
export async function revokeApiKey(id: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
await prisma.apiKey.update({
|
||||||
|
where: { id },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/settings");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a revoked key's record outright. */
|
||||||
|
export async function deleteApiKey(id: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
await prisma.apiKey.delete({ where: { id } });
|
||||||
|
|
||||||
|
revalidatePath("/settings");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
+19
-18
@@ -4,6 +4,12 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { shotSchema, assetSchema, supervisionSchema, previsSchema, lineItemSchema, taskTemplateSchema } from "@/lib/validations";
|
import { shotSchema, assetSchema, supervisionSchema, previsSchema, lineItemSchema, taskTemplateSchema } from "@/lib/validations";
|
||||||
import { calcLineItem } from "@/lib/calculations";
|
import { calcLineItem } from "@/lib/calculations";
|
||||||
|
import {
|
||||||
|
ShotNumberAllocator,
|
||||||
|
buildShotCode,
|
||||||
|
normalizeEpisode,
|
||||||
|
normalizeScene,
|
||||||
|
} from "@/lib/shot-codes";
|
||||||
|
|
||||||
// ─── External Shots API ──────────────────────────────────────────────────────
|
// ─── External Shots API ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -147,34 +153,29 @@ export async function createShot(jobId: string, data: unknown) {
|
|||||||
});
|
});
|
||||||
if (!job) throw new Error("Job not found");
|
if (!job) throw new Error("Job not found");
|
||||||
|
|
||||||
// Auto-increment shot number scoped to this scene (and episode for episodic)
|
// Numbering and code construction are shared with the v1 API so the UI and
|
||||||
const sceneKey = (validated.scene || "SC").toUpperCase();
|
// API always produce identical shot codes.
|
||||||
const episodeKey = validated.episode ? validated.episode.toUpperCase() : null;
|
const scene = normalizeScene(validated.scene);
|
||||||
const agg = await prisma.shot.aggregate({
|
const episode = normalizeEpisode(validated.episode);
|
||||||
|
|
||||||
|
const peers = await prisma.shot.findMany({
|
||||||
where: {
|
where: {
|
||||||
jobId,
|
jobId,
|
||||||
scene: sceneKey,
|
scene,
|
||||||
...(job.jobType === "EPISODIC" ? { episode: episodeKey } : {}),
|
...(job.jobType === "EPISODIC" ? { episode } : {}),
|
||||||
},
|
},
|
||||||
_max: { shotNumber: true },
|
select: { scene: true, episode: true, shotNumber: true },
|
||||||
});
|
});
|
||||||
const shotNumber = (agg._max.shotNumber ?? 0) + 10;
|
const shotNumber = new ShotNumberAllocator(job, peers).next(scene, episode);
|
||||||
const paddedNumber = String(shotNumber).padStart(4, "0");
|
|
||||||
|
|
||||||
// Build shot code
|
|
||||||
const showId = (job.showId || "UNK").toUpperCase();
|
|
||||||
const scene = (validated.scene || "SC").toUpperCase();
|
|
||||||
const shotCode =
|
|
||||||
job.jobType === "EPISODIC" && validated.episode
|
|
||||||
? `${showId}_${validated.episode.toUpperCase()}_${scene}_${paddedNumber}`
|
|
||||||
: `${showId}_${scene}_${paddedNumber}`;
|
|
||||||
|
|
||||||
const count = await prisma.shot.count({ where: { jobId } });
|
const count = await prisma.shot.count({ where: { jobId } });
|
||||||
const shot = await prisma.shot.create({
|
const shot = await prisma.shot.create({
|
||||||
data: {
|
data: {
|
||||||
...validated,
|
...validated,
|
||||||
jobId,
|
jobId,
|
||||||
shotCode,
|
scene,
|
||||||
|
episode,
|
||||||
|
shotCode: buildShotCode(job, scene, episode, shotNumber),
|
||||||
shotNumber,
|
shotNumber,
|
||||||
sortOrder: count,
|
sortOrder: count,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { ApiProblem, apiCreated, apiOk } from "@/lib/api-response";
|
||||||
|
import { apiRoute, parseOneOrMany, type IdContext } from "@/lib/api-route";
|
||||||
|
import { apiLineItemSchema } from "@/lib/api-validations";
|
||||||
|
import { loadRateCards, materializeLineItem } from "@/lib/api-quotes";
|
||||||
|
|
||||||
|
async function findAsset(id: string) {
|
||||||
|
const asset = await prisma.asset.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, jobId: true, complexityMultiplier: true },
|
||||||
|
});
|
||||||
|
if (!asset) throw new ApiProblem("not_found", "No asset exists with that id.");
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1/assets/{id}/line-items — the asset's tasks. */
|
||||||
|
export const GET = apiRoute<IdContext>("read", async (_req, ctx) => {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
await findAsset(id);
|
||||||
|
|
||||||
|
const lineItems = await prisma.lineItem.findMany({
|
||||||
|
where: { assetId: id },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
include: { rateCard: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return apiOk(lineItems, { total: lineItems.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/assets/{id}/line-items — add one task or an array of them.
|
||||||
|
* Totals are computed server-side from the asset's complexity multiplier.
|
||||||
|
*/
|
||||||
|
export const POST = apiRoute<IdContext>("write", async (req: NextRequest, ctx) => {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const asset = await findAsset(id);
|
||||||
|
const { items, wasArray } = await parseOneOrMany(apiLineItemSchema, req);
|
||||||
|
|
||||||
|
const created = await prisma.$transaction(async (tx) => {
|
||||||
|
const rateCards = await loadRateCards(tx, items);
|
||||||
|
const existing = await tx.lineItem.count({ where: { assetId: asset.id } });
|
||||||
|
const complexity = asset.complexityMultiplier.toString();
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const [index, item] of items.entries()) {
|
||||||
|
results.push(
|
||||||
|
await tx.lineItem.create({
|
||||||
|
data: {
|
||||||
|
...materializeLineItem(item, rateCards, complexity, existing + index),
|
||||||
|
assetId: asset.id,
|
||||||
|
},
|
||||||
|
include: { rateCard: true },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/jobs/${asset.jobId}`);
|
||||||
|
return apiCreated(wasArray ? created : created[0]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { ApiProblem, apiOk, readJsonBody } from "@/lib/api-response";
|
||||||
|
import { apiRoute, type IdContext } from "@/lib/api-route";
|
||||||
|
import { apiAssetUpdateSchema } from "@/lib/api-validations";
|
||||||
|
import { assetInclude } from "@/lib/api-quotes";
|
||||||
|
|
||||||
|
async function findAsset(id: string) {
|
||||||
|
const asset = await prisma.asset.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, jobId: true },
|
||||||
|
});
|
||||||
|
if (!asset) throw new ApiProblem("not_found", "No asset exists with that id.");
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1/assets/{id} — one asset with its tasks. */
|
||||||
|
export const GET = apiRoute<IdContext>("read", async (_req, ctx) => {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const asset = await prisma.asset.findUnique({ where: { id }, include: assetInclude });
|
||||||
|
if (!asset) throw new ApiProblem("not_found", "No asset exists with that id.");
|
||||||
|
return apiOk(asset);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/v1/assets/{id} — update asset fields. Tasks are managed through
|
||||||
|
* `/api/v1/assets/{id}/line-items`.
|
||||||
|
*/
|
||||||
|
export const PATCH = apiRoute<IdContext>("write", async (req: NextRequest, ctx) => {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const asset = await findAsset(id);
|
||||||
|
const input = apiAssetUpdateSchema.parse(await readJsonBody(req));
|
||||||
|
|
||||||
|
const data = Object.fromEntries(
|
||||||
|
Object.entries(input).filter(([, value]) => value !== undefined)
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = await prisma.asset.update({
|
||||||
|
where: { id: asset.id },
|
||||||
|
data,
|
||||||
|
include: assetInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/jobs/${asset.jobId}`);
|
||||||
|
return apiOk(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** DELETE /api/v1/assets/{id} — delete the asset and its tasks. */
|
||||||
|
export const DELETE = apiRoute<IdContext>("write", async (_req, ctx) => {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const asset = await findAsset(id);
|
||||||
|
|
||||||
|
await prisma.asset.delete({ where: { id: asset.id } });
|
||||||
|
|
||||||
|
revalidatePath(`/jobs/${asset.jobId}`);
|
||||||
|
return apiOk({ deleted: true, id: asset.id });
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { apiOk } from "@/lib/api-response";
|
||||||
|
import { apiRoute, parseQuery } from "@/lib/api-route";
|
||||||
|
import { apiListQuerySchema } from "@/lib/api-validations";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/clients — list clients, so a caller can find a `clientId` before
|
||||||
|
* creating a quote.
|
||||||
|
*
|
||||||
|
* Query: `limit` (1–100, default 25), `offset`, `search` (company name or
|
||||||
|
* contact person).
|
||||||
|
*
|
||||||
|
* Clients are read-only here; to attach a new one, pass a `client` object when
|
||||||
|
* creating a quote and it is matched by company name or created.
|
||||||
|
*/
|
||||||
|
export const GET = apiRoute<unknown>("read", async (req: NextRequest) => {
|
||||||
|
const { limit, offset, search } = parseQuery(apiListQuerySchema, req);
|
||||||
|
|
||||||
|
const where = search
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ companyName: { contains: search, mode: "insensitive" as const } },
|
||||||
|
{ contactPerson: { contains: search, mode: "insensitive" as const } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const [clients, total] = await Promise.all([
|
||||||
|
prisma.client.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { companyName: "asc" },
|
||||||
|
take: limit,
|
||||||
|
skip: offset,
|
||||||
|
include: { _count: { select: { jobs: true, invoices: true } } },
|
||||||
|
}),
|
||||||
|
prisma.client.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return apiOk(clients, { total, limit, offset, returned: clients.length });
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/** Alias for `/api/v1/quotes/{idOrCode}/assets`. */
|
||||||
|
export { GET, POST } from "../../../quotes/[id]/assets/route";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/** Alias for `/api/v1/quotes/{idOrCode}/recalculate`. */
|
||||||
|
export { POST } from "../../../quotes/[id]/recalculate/route";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/** Alias for `/api/v1/quotes/{idOrCode}`. */
|
||||||
|
export { GET, PATCH, DELETE } from "../../quotes/[id]/route";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/** Alias for `/api/v1/quotes/{idOrCode}/shots`. */
|
||||||
|
export { GET, POST } from "../../../quotes/[id]/shots/route";
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* `/api/v1/jobs` is an alias for `/api/v1/quotes` — a quote and a job are the
|
||||||
|
* same record in this app, and callers reach for either name.
|
||||||
|
*/
|
||||||
|
export { GET, POST } from "../quotes/route";
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { ApiProblem, apiOk, readJsonBody } from "@/lib/api-response";
|
||||||
|
import { apiRoute, type IdContext } from "@/lib/api-route";
|
||||||
|
import { apiLineItemSchema } from "@/lib/api-validations";
|
||||||
|
import { loadRateCards, materializeLineItem } from "@/lib/api-quotes";
|
||||||
|
|
||||||
|
const parentSelect = { jobId: true, complexityMultiplier: true } as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A line item hangs off exactly one of shot / asset / supervision / previs,
|
||||||
|
* and needs that parent's complexity multiplier to price itself.
|
||||||
|
*/
|
||||||
|
async function findLineItem(id: string) {
|
||||||
|
const lineItem = await prisma.lineItem.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
shot: { select: parentSelect },
|
||||||
|
asset: { select: parentSelect },
|
||||||
|
supervision: { select: parentSelect },
|
||||||
|
previs: { select: parentSelect },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!lineItem) throw new ApiProblem("not_found", "No line item exists with that id.");
|
||||||
|
|
||||||
|
const parent = lineItem.shot ?? lineItem.asset ?? lineItem.supervision ?? lineItem.previs;
|
||||||
|
return { lineItem, parent };
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = (value: Prisma.Decimal | number | string) => Number(value.toString());
|
||||||
|
const numOrNull = (value: Prisma.Decimal | null) => (value === null ? null : num(value));
|
||||||
|
|
||||||
|
const apiLineItemUpdateSchema = apiLineItemSchema.partial();
|
||||||
|
|
||||||
|
/** GET /api/v1/line-items/{id} — one task. */
|
||||||
|
export const GET = apiRoute<IdContext>("read", async (_req, ctx) => {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const lineItem = await prisma.lineItem.findUnique({ where: { id }, include: { rateCard: true } });
|
||||||
|
if (!lineItem) throw new ApiProblem("not_found", "No line item exists with that id.");
|
||||||
|
return apiOk(lineItem);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/v1/line-items/{id} — update a task and reprice it.
|
||||||
|
*
|
||||||
|
* Omitted fields keep their stored values; `manualHourlyRate` and
|
||||||
|
* `manualSubtotal` accept `null` to drop the override and go back to the
|
||||||
|
* derived figure.
|
||||||
|
*/
|
||||||
|
export const PATCH = apiRoute<IdContext>("write", async (req: NextRequest, ctx) => {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const { lineItem, parent } = await findLineItem(id);
|
||||||
|
const input = apiLineItemUpdateSchema.parse(await readJsonBody(req));
|
||||||
|
|
||||||
|
// A rate card named in the patch replaces whatever was linked before;
|
||||||
|
// otherwise the existing link stands.
|
||||||
|
const namedCard = Boolean(input.rateCardId || input.rateCardName);
|
||||||
|
|
||||||
|
const merged = {
|
||||||
|
taskName: input.taskName ?? lineItem.taskName,
|
||||||
|
artistRole: input.artistRole ?? lineItem.artistRole ?? undefined,
|
||||||
|
rateCardId: input.rateCardId ?? (namedCard ? undefined : (lineItem.rateCardId ?? undefined)),
|
||||||
|
rateCardName: input.rateCardName,
|
||||||
|
quantityHours: input.quantityHours ?? num(lineItem.quantityHours),
|
||||||
|
dailyRate: input.dailyRate ?? num(lineItem.dailyRate),
|
||||||
|
hoursPerDay: input.hoursPerDay ?? num(lineItem.hoursPerDay),
|
||||||
|
numArtists: input.numArtists ?? num(lineItem.numArtists),
|
||||||
|
numDays: input.numDays ?? num(lineItem.numDays),
|
||||||
|
manualHourlyRate:
|
||||||
|
input.manualHourlyRate !== undefined
|
||||||
|
? input.manualHourlyRate
|
||||||
|
: numOrNull(lineItem.manualHourlyRate),
|
||||||
|
manualSubtotal:
|
||||||
|
input.manualSubtotal !== undefined
|
||||||
|
? input.manualSubtotal
|
||||||
|
: numOrNull(lineItem.manualSubtotal),
|
||||||
|
markupPercent: input.markupPercent ?? num(lineItem.markupPercent),
|
||||||
|
discountPercent: input.discountPercent ?? num(lineItem.discountPercent),
|
||||||
|
taxPercent: input.taxPercent ?? num(lineItem.taxPercent),
|
||||||
|
notes: input.notes ?? lineItem.notes ?? undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const rateCards = await loadRateCards(prisma, [merged]);
|
||||||
|
const complexity = parent ? parent.complexityMultiplier.toString() : "1";
|
||||||
|
|
||||||
|
const updated = await prisma.lineItem.update({
|
||||||
|
where: { id: lineItem.id },
|
||||||
|
data: materializeLineItem(merged, rateCards, complexity, lineItem.sortOrder),
|
||||||
|
include: { rateCard: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parent) revalidatePath(`/jobs/${parent.jobId}`);
|
||||||
|
return apiOk(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** DELETE /api/v1/line-items/{id} — remove a task. */
|
||||||
|
export const DELETE = apiRoute<IdContext>("write", async (_req, ctx) => {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const { lineItem, parent } = await findLineItem(id);
|
||||||
|
|
||||||
|
await prisma.lineItem.delete({ where: { id: lineItem.id } });
|
||||||
|
|
||||||
|
if (parent) revalidatePath(`/jobs/${parent.jobId}`);
|
||||||
|
return apiOk({ deleted: true, id: lineItem.id });
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { ApiProblem, apiCreated, apiOk } from "@/lib/api-response";
|
||||||
|
import { apiRoute, parseOneOrMany, type IdContext } from "@/lib/api-route";
|
||||||
|
import { apiAssetCreateSchema } from "@/lib/api-validations";
|
||||||
|
import {
|
||||||
|
assetInclude,
|
||||||
|
buildAssetCreateData,
|
||||||
|
collectLineItems,
|
||||||
|
loadRateCards,
|
||||||
|
} from "@/lib/api-quotes";
|
||||||
|
|
||||||
|
async function findQuoteId(idOrCode: string): Promise<string> {
|
||||||
|
const quote = await prisma.job.findFirst({
|
||||||
|
where: { OR: [{ id: idOrCode }, { projectCode: idOrCode }] },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!quote) throw new ApiProblem("not_found", "No quote exists with that id or projectCode.");
|
||||||
|
return quote.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1/quotes/{idOrCode}/assets — the quote's assets, with their tasks. */
|
||||||
|
export const GET = apiRoute<IdContext>("read", async (_req, ctx) => {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const jobId = await findQuoteId(id);
|
||||||
|
|
||||||
|
const assets = await prisma.asset.findMany({
|
||||||
|
where: { jobId },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
include: assetInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
return apiOk(assets, { total: assets.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/quotes/{idOrCode}/assets — add one asset or an array of them,
|
||||||
|
* each optionally carrying its own `lineItems`.
|
||||||
|
*/
|
||||||
|
export const POST = apiRoute<IdContext>("write", async (req: NextRequest, ctx) => {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const jobId = await findQuoteId(id);
|
||||||
|
const { items, wasArray } = await parseOneOrMany(apiAssetCreateSchema, req);
|
||||||
|
|
||||||
|
const created = await prisma.$transaction(async (tx) => {
|
||||||
|
const rateCards = await loadRateCards(tx, collectLineItems({ assets: items }));
|
||||||
|
const existing = await tx.asset.count({ where: { jobId } });
|
||||||
|
const payloads = buildAssetCreateData(items, rateCards, existing);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const data of payloads) {
|
||||||
|
results.push(await tx.asset.create({ data: { ...data, jobId }, include: assetInclude }));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/jobs/${jobId}`);
|
||||||
|
return apiCreated(wasArray ? created : created[0]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { recalculateJobTotals } from "@/actions/jobs";
|
||||||
|
import { ApiProblem, apiOk } from "@/lib/api-response";
|
||||||
|
import { apiRoute, type IdContext } from "@/lib/api-route";
|
||||||
|
import { quoteInclude } from "@/lib/api-quotes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/quotes/{idOrCode}/recalculate
|
||||||
|
*
|
||||||
|
* Recompute and re-store every line item total on the quote. The write
|
||||||
|
* endpoints already price each task as it is created, so this is for repairing
|
||||||
|
* rows written by other means — a restored backup, a direct SQL edit, or a
|
||||||
|
* complexity multiplier changed without touching its tasks.
|
||||||
|
*/
|
||||||
|
export const POST = apiRoute<IdContext>("write", async (_req, ctx) => {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
|
||||||
|
const quote = await prisma.job.findFirst({
|
||||||
|
where: { OR: [{ id }, { projectCode: id }] },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!quote) throw new ApiProblem("not_found", "No quote exists with that id or projectCode.");
|
||||||
|
|
||||||
|
await recalculateJobTotals(quote.id);
|
||||||
|
|
||||||
|
const refreshed = await prisma.job.findUnique({
|
||||||
|
where: { id: quote.id },
|
||||||
|
include: quoteInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/jobs/${quote.id}`);
|
||||||
|
return apiOk(refreshed);
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { ApiProblem, apiOk, readJsonBody } from "@/lib/api-response";
|
||||||
|
import { apiRoute, type IdContext } from "@/lib/api-route";
|
||||||
|
import { apiQuoteUpdateSchema } from "@/lib/api-validations";
|
||||||
|
import { quoteInclude, resolveClientId } from "@/lib/api-quotes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quotes can be addressed by `id` or by their unique `projectCode`, so callers
|
||||||
|
* holding a code from another system do not need to resolve it first.
|
||||||
|
*/
|
||||||
|
async function findQuoteId(idOrCode: string): Promise<string> {
|
||||||
|
const quote = await prisma.job.findFirst({
|
||||||
|
where: { OR: [{ id: idOrCode }, { projectCode: idOrCode }] },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!quote) throw new ApiProblem("not_found", "No quote exists with that id or projectCode.");
|
||||||
|
return quote.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1/quotes/{idOrCode} — the full quote graph. */
|
||||||
|
export const GET = apiRoute<IdContext>("read", async (_req, ctx) => {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const quote = await prisma.job.findFirst({
|
||||||
|
where: { OR: [{ id }, { projectCode: id }] },
|
||||||
|
include: quoteInclude,
|
||||||
|
});
|
||||||
|
if (!quote) throw new ApiProblem("not_found", "No quote exists with that id or projectCode.");
|
||||||
|
return apiOk(quote);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/v1/quotes/{idOrCode} — update quote-level fields only.
|
||||||
|
*
|
||||||
|
* Shots, assets and tasks are managed through their own endpoints. Omitted
|
||||||
|
* fields are left as they are; `expiryDate`, `startDate` and
|
||||||
|
* `estimatedCompletionDate` accept `null` to clear them.
|
||||||
|
*/
|
||||||
|
export const PATCH = apiRoute<IdContext>("write", async (req: NextRequest, ctx) => {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const jobId = await findQuoteId(id);
|
||||||
|
const input = apiQuoteUpdateSchema.parse(await readJsonBody(req));
|
||||||
|
|
||||||
|
if (input.clientId) {
|
||||||
|
await resolveClientId(prisma, { clientId: input.clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only keys the caller actually sent are written, so a PATCH never resets a
|
||||||
|
// field by omission.
|
||||||
|
const data = Object.fromEntries(
|
||||||
|
Object.entries(input).filter(([, value]) => value !== undefined)
|
||||||
|
);
|
||||||
|
|
||||||
|
const quote = await prisma.job.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data,
|
||||||
|
include: quoteInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/jobs");
|
||||||
|
revalidatePath(`/jobs/${jobId}`);
|
||||||
|
return apiOk(quote);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/v1/quotes/{idOrCode} — delete the quote and everything that
|
||||||
|
* cascades from it (shots, assets, tasks, payment schedule, invoices).
|
||||||
|
*/
|
||||||
|
export const DELETE = apiRoute<IdContext>("write", async (_req, ctx) => {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const jobId = await findQuoteId(id);
|
||||||
|
|
||||||
|
await prisma.job.delete({ where: { id: jobId } });
|
||||||
|
|
||||||
|
revalidatePath("/jobs");
|
||||||
|
return apiOk({ deleted: true, id: jobId });
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { ApiProblem, apiCreated, apiOk } from "@/lib/api-response";
|
||||||
|
import { apiRoute, parseOneOrMany, type IdContext } from "@/lib/api-route";
|
||||||
|
import { apiShotCreateSchema } from "@/lib/api-validations";
|
||||||
|
import {
|
||||||
|
buildShotCreateData,
|
||||||
|
collectLineItems,
|
||||||
|
loadRateCards,
|
||||||
|
shotAllocatorForJob,
|
||||||
|
shotInclude,
|
||||||
|
} from "@/lib/api-quotes";
|
||||||
|
|
||||||
|
async function findQuote(idOrCode: string) {
|
||||||
|
const quote = await prisma.job.findFirst({
|
||||||
|
where: { OR: [{ id: idOrCode }, { projectCode: idOrCode }] },
|
||||||
|
select: { id: true, showId: true, jobType: true },
|
||||||
|
});
|
||||||
|
if (!quote) throw new ApiProblem("not_found", "No quote exists with that id or projectCode.");
|
||||||
|
return quote;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1/quotes/{idOrCode}/shots — the quote's shots, with their tasks. */
|
||||||
|
export const GET = apiRoute<IdContext>("read", async (_req, ctx) => {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const quote = await findQuote(id);
|
||||||
|
|
||||||
|
const shots = await prisma.shot.findMany({
|
||||||
|
where: { jobId: quote.id },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
include: shotInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
return apiOk(shots, { total: shots.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/quotes/{idOrCode}/shots — add one shot or an array of shots,
|
||||||
|
* each optionally carrying its own `lineItems`.
|
||||||
|
*
|
||||||
|
* Shot codes are generated per scene (and per episode on episodic quotes),
|
||||||
|
* continuing from the highest number already used, unless the caller supplies
|
||||||
|
* an explicit `shotCode`.
|
||||||
|
*/
|
||||||
|
export const POST = apiRoute<IdContext>("write", async (req: NextRequest, ctx) => {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const quote = await findQuote(id);
|
||||||
|
const { items, wasArray } = await parseOneOrMany(apiShotCreateSchema, req);
|
||||||
|
|
||||||
|
const created = await prisma.$transaction(async (tx) => {
|
||||||
|
const rateCards = await loadRateCards(tx, collectLineItems({ shots: items }));
|
||||||
|
const { allocator, sortOrder, codes } = await shotAllocatorForJob(tx, quote.id, quote);
|
||||||
|
const payloads = buildShotCreateData(quote, items, rateCards, allocator, sortOrder, codes);
|
||||||
|
|
||||||
|
// Sequential rather than concurrent: shot codes are unique per quote and
|
||||||
|
// these share an allocator, so a predictable order keeps numbering stable.
|
||||||
|
const results = [];
|
||||||
|
for (const data of payloads) {
|
||||||
|
results.push(
|
||||||
|
await tx.shot.create({ data: { ...data, jobId: quote.id }, include: shotInclude })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/jobs/${quote.id}`);
|
||||||
|
return apiCreated(wasArray ? created : created[0]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { apiCreated, apiOk, readJsonBody } from "@/lib/api-response";
|
||||||
|
import { apiRoute, parseQuery } from "@/lib/api-route";
|
||||||
|
import { apiListQuerySchema, apiQuoteCreateSchema } from "@/lib/api-validations";
|
||||||
|
import { createQuoteFromApi } from "@/lib/api-quotes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/quotes — list quotes, newest first.
|
||||||
|
*
|
||||||
|
* Query: `limit` (1–100, default 25), `offset`, `status`, `clientId`,
|
||||||
|
* `search` (matches project title or code).
|
||||||
|
*/
|
||||||
|
export const GET = apiRoute<unknown>("read", async (req: NextRequest) => {
|
||||||
|
const { limit, offset, status, clientId, search } = parseQuery(apiListQuerySchema, req);
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
...(clientId ? { clientId } : {}),
|
||||||
|
...(search
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ projectTitle: { contains: search, mode: "insensitive" as const } },
|
||||||
|
{ projectCode: { contains: search, mode: "insensitive" as const } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [quotes, total] = await Promise.all([
|
||||||
|
prisma.job.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: limit,
|
||||||
|
skip: offset,
|
||||||
|
include: {
|
||||||
|
client: { select: { id: true, companyName: true, contactPerson: true } },
|
||||||
|
_count: { select: { shots: true, assets: true, supervisions: true, previses: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.job.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return apiOk(quotes, { total, limit, offset, returned: quotes.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/quotes — create a quote, optionally with its whole
|
||||||
|
* shot/asset/task tree in the same request. Runs in one transaction.
|
||||||
|
*/
|
||||||
|
export const POST = apiRoute<unknown>("write", async (req: NextRequest) => {
|
||||||
|
const input = apiQuoteCreateSchema.parse(await readJsonBody(req));
|
||||||
|
const quote = await createQuoteFromApi(input);
|
||||||
|
|
||||||
|
revalidatePath("/jobs");
|
||||||
|
return apiCreated(quote);
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { apiOk } from "@/lib/api-response";
|
||||||
|
import { apiRoute, parseQuery } from "@/lib/api-route";
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
/** Inactive cards are hidden unless asked for. */
|
||||||
|
includeInactive: z
|
||||||
|
.enum(["1", "true", "0", "false"])
|
||||||
|
.optional()
|
||||||
|
.transform((v) => v === "1" || v === "true"),
|
||||||
|
search: z.string().trim().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/rate-cards — list rate cards.
|
||||||
|
*
|
||||||
|
* Tasks can reference a card by `rateCardId` or `rateCardName`, inheriting its
|
||||||
|
* daily rate and hours per day, so this is how a caller discovers what is
|
||||||
|
* available to quote against.
|
||||||
|
*/
|
||||||
|
export const GET = apiRoute<unknown>("read", async (req: NextRequest) => {
|
||||||
|
const { includeInactive, search } = parseQuery(querySchema, req);
|
||||||
|
|
||||||
|
const rateCards = await prisma.rateCard.findMany({
|
||||||
|
where: {
|
||||||
|
...(includeInactive ? {} : { isActive: true }),
|
||||||
|
...(search
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: search, mode: "insensitive" as const } },
|
||||||
|
{ department: { contains: search, mode: "insensitive" as const } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ department: "asc" }, { name: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return apiOk(rateCards, { total: rateCards.length });
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { apiOk } from "@/lib/api-response";
|
||||||
|
import { apiRoute } from "@/lib/api-route";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1 — discovery and key check.
|
||||||
|
*
|
||||||
|
* Echoes back which key authenticated and what it may do, which makes this the
|
||||||
|
* endpoint to curl first when setting up an integration.
|
||||||
|
*/
|
||||||
|
export const GET = apiRoute<unknown>("read", async (_req, _ctx, key) =>
|
||||||
|
apiOk({
|
||||||
|
name: "xBid API",
|
||||||
|
version: "v1",
|
||||||
|
authenticatedAs: { id: key.id, name: key.name, scopes: key.scopes },
|
||||||
|
notes: [
|
||||||
|
"Decimal fields are returned as strings to preserve exact precision.",
|
||||||
|
"Quotes may be addressed by id or by projectCode.",
|
||||||
|
"Endpoints under /api/v1/jobs are aliases for /api/v1/quotes.",
|
||||||
|
],
|
||||||
|
endpoints: {
|
||||||
|
quotes: {
|
||||||
|
"GET /api/v1/quotes": "List quotes (limit, offset, status, clientId, search)",
|
||||||
|
"POST /api/v1/quotes": "Create a quote, optionally with nested shots, assets and tasks",
|
||||||
|
"GET /api/v1/quotes/{idOrCode}": "Fetch the full quote graph",
|
||||||
|
"PATCH /api/v1/quotes/{idOrCode}": "Update quote-level fields",
|
||||||
|
"DELETE /api/v1/quotes/{idOrCode}": "Delete a quote and everything under it",
|
||||||
|
"POST /api/v1/quotes/{idOrCode}/recalculate": "Reprice every task on the quote",
|
||||||
|
},
|
||||||
|
shots: {
|
||||||
|
"GET /api/v1/quotes/{idOrCode}/shots": "List a quote's shots",
|
||||||
|
"POST /api/v1/quotes/{idOrCode}/shots": "Add one shot or an array of shots",
|
||||||
|
"GET /api/v1/shots/{id}": "Fetch one shot with its tasks",
|
||||||
|
"PATCH /api/v1/shots/{id}": "Update a shot",
|
||||||
|
"DELETE /api/v1/shots/{id}": "Delete a shot",
|
||||||
|
"GET /api/v1/shots/{id}/line-items": "List a shot's tasks",
|
||||||
|
"POST /api/v1/shots/{id}/line-items": "Add one task or an array of tasks",
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
"GET /api/v1/quotes/{idOrCode}/assets": "List a quote's assets",
|
||||||
|
"POST /api/v1/quotes/{idOrCode}/assets": "Add one asset or an array of assets",
|
||||||
|
"GET /api/v1/assets/{id}": "Fetch one asset with its tasks",
|
||||||
|
"PATCH /api/v1/assets/{id}": "Update an asset",
|
||||||
|
"DELETE /api/v1/assets/{id}": "Delete an asset",
|
||||||
|
"GET /api/v1/assets/{id}/line-items": "List an asset's tasks",
|
||||||
|
"POST /api/v1/assets/{id}/line-items": "Add one task or an array of tasks",
|
||||||
|
},
|
||||||
|
lineItems: {
|
||||||
|
"GET /api/v1/line-items/{id}": "Fetch one task",
|
||||||
|
"PATCH /api/v1/line-items/{id}": "Update and reprice a task",
|
||||||
|
"DELETE /api/v1/line-items/{id}": "Delete a task",
|
||||||
|
},
|
||||||
|
reference: {
|
||||||
|
"GET /api/v1/clients": "List clients (read-only)",
|
||||||
|
"GET /api/v1/rate-cards": "List rate cards (read-only)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { ApiProblem, apiCreated, apiOk } from "@/lib/api-response";
|
||||||
|
import { apiRoute, parseOneOrMany, type IdContext } from "@/lib/api-route";
|
||||||
|
import { apiLineItemSchema } from "@/lib/api-validations";
|
||||||
|
import { loadRateCards, materializeLineItem } from "@/lib/api-quotes";
|
||||||
|
|
||||||
|
async function findShot(id: string) {
|
||||||
|
const shot = await prisma.shot.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, jobId: true, complexityMultiplier: true },
|
||||||
|
});
|
||||||
|
if (!shot) throw new ApiProblem("not_found", "No shot exists with that id.");
|
||||||
|
return shot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1/shots/{id}/line-items — the shot's tasks. */
|
||||||
|
export const GET = apiRoute<IdContext>("read", async (_req, ctx) => {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
await findShot(id);
|
||||||
|
|
||||||
|
const lineItems = await prisma.lineItem.findMany({
|
||||||
|
where: { shotId: id },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
include: { rateCard: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return apiOk(lineItems, { total: lineItems.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/shots/{id}/line-items — add one task or an array of them.
|
||||||
|
*
|
||||||
|
* Totals are computed on the server from the shot's complexity multiplier, so
|
||||||
|
* stored money always matches what the PDF and CSV exports will show.
|
||||||
|
*/
|
||||||
|
export const POST = apiRoute<IdContext>("write", async (req: NextRequest, ctx) => {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const shot = await findShot(id);
|
||||||
|
const { items, wasArray } = await parseOneOrMany(apiLineItemSchema, req);
|
||||||
|
|
||||||
|
const created = await prisma.$transaction(async (tx) => {
|
||||||
|
const rateCards = await loadRateCards(tx, items);
|
||||||
|
const existing = await tx.lineItem.count({ where: { shotId: shot.id } });
|
||||||
|
const complexity = shot.complexityMultiplier.toString();
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const [index, item] of items.entries()) {
|
||||||
|
results.push(
|
||||||
|
await tx.lineItem.create({
|
||||||
|
data: {
|
||||||
|
...materializeLineItem(item, rateCards, complexity, existing + index),
|
||||||
|
shotId: shot.id,
|
||||||
|
},
|
||||||
|
include: { rateCard: true },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/jobs/${shot.jobId}`);
|
||||||
|
return apiCreated(wasArray ? created : created[0]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { ApiProblem, apiOk, readJsonBody } from "@/lib/api-response";
|
||||||
|
import { apiRoute, type IdContext } from "@/lib/api-route";
|
||||||
|
import { apiShotUpdateSchema } from "@/lib/api-validations";
|
||||||
|
import { shotInclude } from "@/lib/api-quotes";
|
||||||
|
import { normalizeEpisode, normalizeScene } from "@/lib/shot-codes";
|
||||||
|
|
||||||
|
async function findShot(id: string) {
|
||||||
|
const shot = await prisma.shot.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, jobId: true },
|
||||||
|
});
|
||||||
|
if (!shot) throw new ApiProblem("not_found", "No shot exists with that id.");
|
||||||
|
return shot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1/shots/{id} — one shot with its tasks. */
|
||||||
|
export const GET = apiRoute<IdContext>("read", async (_req, ctx) => {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const shot = await prisma.shot.findUnique({ where: { id }, include: shotInclude });
|
||||||
|
if (!shot) throw new ApiProblem("not_found", "No shot exists with that id.");
|
||||||
|
return apiOk(shot);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/v1/shots/{id} — update shot fields.
|
||||||
|
*
|
||||||
|
* An existing `shotCode` is left alone unless the caller sends a new one, so
|
||||||
|
* changing a shot's scene does not silently renumber it. Tasks are managed
|
||||||
|
* through `/api/v1/shots/{id}/line-items`.
|
||||||
|
*/
|
||||||
|
export const PATCH = apiRoute<IdContext>("write", async (req: NextRequest, ctx) => {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const shot = await findShot(id);
|
||||||
|
const input = apiShotUpdateSchema.parse(await readJsonBody(req));
|
||||||
|
|
||||||
|
const data = Object.fromEntries(
|
||||||
|
Object.entries(input).filter(([, value]) => value !== undefined)
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (typeof data.scene === "string") data.scene = normalizeScene(data.scene);
|
||||||
|
if (typeof data.episode === "string") data.episode = normalizeEpisode(data.episode);
|
||||||
|
if (typeof data.shotCode === "string") data.shotCode = data.shotCode.trim().toUpperCase();
|
||||||
|
|
||||||
|
const updated = await prisma.shot.update({
|
||||||
|
where: { id: shot.id },
|
||||||
|
data,
|
||||||
|
include: shotInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/jobs/${shot.jobId}`);
|
||||||
|
return apiOk(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** DELETE /api/v1/shots/{id} — delete the shot and its tasks. */
|
||||||
|
export const DELETE = apiRoute<IdContext>("write", async (_req, ctx) => {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const shot = await findShot(id);
|
||||||
|
|
||||||
|
await prisma.shot.delete({ where: { id: shot.id } });
|
||||||
|
|
||||||
|
revalidatePath(`/jobs/${shot.jobId}`);
|
||||||
|
return apiOk({ deleted: true, id: shot.id });
|
||||||
|
});
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
import { getStudioSettings } from "@/actions/settings";
|
import { getStudioSettings } from "@/actions/settings";
|
||||||
|
import { listApiKeys } from "@/actions/api-keys";
|
||||||
import { SettingsForm } from "@/components/settings/settings-form";
|
import { SettingsForm } from "@/components/settings/settings-form";
|
||||||
|
import { ApiKeysManager, type ApiKeyRow } from "@/components/settings/api-keys-manager";
|
||||||
import { Settings } from "lucide-react";
|
import { Settings } from "lucide-react";
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const rawSettings = await getStudioSettings();
|
const rawSettings = await getStudioSettings();
|
||||||
const settings = JSON.parse(JSON.stringify(rawSettings));
|
const settings = JSON.parse(JSON.stringify(rawSettings));
|
||||||
|
|
||||||
|
// Key management is admin-only, so the section is fetched and rendered only
|
||||||
|
// for admins rather than letting the action throw and take the page with it.
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
const isAdmin = session?.user?.role === "ADMIN";
|
||||||
|
const apiKeys: ApiKeyRow[] = isAdmin ? await listApiKeys() : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl mx-auto space-y-6">
|
<div className="p-6 max-w-4xl mx-auto space-y-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -23,6 +33,12 @@ export default async function SettingsPage() {
|
|||||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6">
|
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6">
|
||||||
<SettingsForm initialData={settings} />
|
<SettingsForm initialData={settings} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6">
|
||||||
|
<ApiKeysManager initialKeys={apiKeys} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm, useWatch } from "react-hook-form";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { createApiKey, revokeApiKey, deleteApiKey } from "@/actions/api-keys";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Check, Copy, KeyRound, Loader2, Plus, Trash2, TriangleAlert } from "lucide-react";
|
||||||
|
|
||||||
|
export interface ApiKeyRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
scopes: string[];
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
expiresAt: string | null;
|
||||||
|
revokedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateFormValues = { name: string; canWrite: boolean };
|
||||||
|
|
||||||
|
const relative = (value: string | null) =>
|
||||||
|
value ? formatDistanceToNow(new Date(value), { addSuffix: true }) : null;
|
||||||
|
|
||||||
|
/** Shown once, right after minting — the plaintext key is not recoverable. */
|
||||||
|
function NewKeyNotice({ apiKey, onDone }: { apiKey: string; onDone: () => void }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(apiKey);
|
||||||
|
setCopied(true);
|
||||||
|
toast.success("Key copied to clipboard");
|
||||||
|
} catch {
|
||||||
|
toast.error("Could not copy — select the key and copy it manually");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
|
||||||
|
<TriangleAlert className="h-5 w-5 shrink-0 text-amber-400" />
|
||||||
|
<p className="text-sm text-amber-200">
|
||||||
|
Copy this key now. It is hashed before being stored, so it cannot be shown again — if
|
||||||
|
you lose it, revoke it and create another.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 overflow-x-auto rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-2 font-mono text-xs text-amber-300">
|
||||||
|
{apiKey}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="border-zinc-700 shrink-0"
|
||||||
|
onClick={copy}
|
||||||
|
aria-label="Copy API key"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onDone}
|
||||||
|
className="bg-amber-500 font-semibold text-black hover:bg-amber-400"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateKeyForm({
|
||||||
|
onCreated,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
onCreated: (key: string, row: ApiKeyRow) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
setValue,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<CreateFormValues>({ defaultValues: { name: "", canWrite: true } });
|
||||||
|
|
||||||
|
const canWrite = useWatch({ control, name: "canWrite", defaultValue: true });
|
||||||
|
|
||||||
|
const onSubmit = async (values: CreateFormValues) => {
|
||||||
|
if (!values.name.trim()) return;
|
||||||
|
try {
|
||||||
|
const scopes = values.canWrite ? ["read", "write"] : ["read"];
|
||||||
|
const result = await createApiKey({ name: values.name.trim(), scopes });
|
||||||
|
onCreated(result.key, { ...result.record, lastUsedAt: null, revokedAt: null });
|
||||||
|
toast.success("API key created");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to create API key");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Name *</Label>
|
||||||
|
<Input
|
||||||
|
{...register("name", { required: "Give the key a name" })}
|
||||||
|
placeholder="e.g. Review app integration"
|
||||||
|
className="border-zinc-700 bg-zinc-800"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
Name it after whatever will use it, so you know what breaks if you revoke it.
|
||||||
|
</p>
|
||||||
|
{errors.name && <p className="text-xs text-red-400">{errors.name.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-zinc-700 bg-zinc-800/50 px-3 py-2.5">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-white">Allow writes</p>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
{canWrite
|
||||||
|
? "Can create, update and delete quotes, shots, assets and tasks."
|
||||||
|
: "Read-only: can list and fetch, but not change anything."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch checked={canWrite} onCheckedChange={(v) => setValue("canWrite", v)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button type="button" variant="outline" className="border-zinc-700" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="bg-amber-500 font-semibold text-black hover:bg-amber-400"
|
||||||
|
>
|
||||||
|
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Create Key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiKeysManager({ initialKeys }: { initialKeys: ApiKeyRow[] }) {
|
||||||
|
const [keys, setKeys] = useState(initialKeys);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [newKey, setNewKey] = useState<string | null>(null);
|
||||||
|
const [revokeTarget, setRevokeTarget] = useState<ApiKeyRow | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ApiKeyRow | null>(null);
|
||||||
|
|
||||||
|
const handleCreated = (key: string, row: ApiKeyRow) => {
|
||||||
|
setKeys((prev) => [row, ...prev]);
|
||||||
|
setNewKey(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setNewKey(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevoke = async () => {
|
||||||
|
if (!revokeTarget) return;
|
||||||
|
try {
|
||||||
|
await revokeApiKey(revokeTarget.id);
|
||||||
|
const revokedAt = new Date().toISOString();
|
||||||
|
setKeys((prev) =>
|
||||||
|
prev.map((k) => (k.id === revokeTarget.id ? { ...k, revokedAt } : k))
|
||||||
|
);
|
||||||
|
toast.success("API key revoked");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to revoke key");
|
||||||
|
}
|
||||||
|
setRevokeTarget(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
await deleteApiKey(deleteTarget.id);
|
||||||
|
setKeys((prev) => prev.filter((k) => k.id !== deleteTarget.id));
|
||||||
|
toast.success("API key deleted");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to delete key");
|
||||||
|
}
|
||||||
|
setDeleteTarget(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-5 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-white">API Keys</h2>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
Keys authenticate requests to the xBid API at{" "}
|
||||||
|
<code className="font-mono text-xs text-zinc-300">/api/v1</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => setDialogOpen(true)}
|
||||||
|
className="shrink-0 bg-amber-500 font-semibold text-black hover:bg-amber-400"
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> New Key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{keys.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 py-12 text-center text-zinc-500">
|
||||||
|
<KeyRound className="mx-auto mb-4 h-10 w-10 opacity-30" />
|
||||||
|
<p className="text-sm">No API keys yet.</p>
|
||||||
|
<p className="mt-1 text-xs">Create one to let another system file quotes into xBid.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-zinc-800 text-xs uppercase tracking-wider text-zinc-500">
|
||||||
|
<th className="px-4 py-3 text-left">Name</th>
|
||||||
|
<th className="px-4 py-3 text-left">Key</th>
|
||||||
|
<th className="px-4 py-3 text-left">Scopes</th>
|
||||||
|
<th className="px-4 py-3 text-left">Last used</th>
|
||||||
|
<th className="px-4 py-3 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{keys.map((key) => {
|
||||||
|
const revoked = Boolean(key.revokedAt);
|
||||||
|
const expired = Boolean(key.expiresAt && new Date(key.expiresAt) <= new Date());
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={key.id}
|
||||||
|
className={`border-b border-zinc-800 last:border-0 ${revoked ? "opacity-50" : ""}`}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-white">{key.name}</span>
|
||||||
|
{revoked && (
|
||||||
|
<span className="ml-2 rounded bg-zinc-700 px-1.5 py-0.5 text-xs text-zinc-300">
|
||||||
|
Revoked
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!revoked && expired && (
|
||||||
|
<span className="ml-2 rounded bg-red-500/15 px-1.5 py-0.5 text-xs text-red-400">
|
||||||
|
Expired
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<p className="mt-0.5 text-xs text-zinc-500">
|
||||||
|
Created {relative(key.createdAt)}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<code className="font-mono text-xs text-zinc-400">{key.prefix}…</code>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{key.scopes.map((scope) => (
|
||||||
|
<span
|
||||||
|
key={scope}
|
||||||
|
className="rounded bg-zinc-800 px-1.5 py-0.5 text-xs text-zinc-300"
|
||||||
|
>
|
||||||
|
{scope}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-zinc-400">
|
||||||
|
{relative(key.lastUsedAt) ?? "Never"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{revoked ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-zinc-400 hover:text-red-400"
|
||||||
|
onClick={() => setDeleteTarget(key)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-zinc-700 text-zinc-300"
|
||||||
|
onClick={() => setRevokeTarget(key)}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}>
|
||||||
|
<DialogContent className="border-zinc-800 bg-zinc-900">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{newKey ? "API key created" : "New API key"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{newKey ? (
|
||||||
|
<NewKeyNotice apiKey={newKey} onDone={closeDialog} />
|
||||||
|
) : (
|
||||||
|
<CreateKeyForm onCreated={handleCreated} onCancel={closeDialog} />
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog open={Boolean(revokeTarget)} onOpenChange={(open) => !open && setRevokeTarget(null)}>
|
||||||
|
<AlertDialogContent className="border-zinc-800 bg-zinc-900">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Revoke this key?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Any integration using <strong>{revokeTarget?.name}</strong> will start getting 401
|
||||||
|
responses immediately. This cannot be undone — you would need to issue a new key.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel className="border-zinc-700">Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleRevoke}
|
||||||
|
className="bg-red-600 text-white hover:bg-red-500"
|
||||||
|
>
|
||||||
|
Revoke key
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog open={Boolean(deleteTarget)} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||||
|
<AlertDialogContent className="border-zinc-800 bg-zinc-900">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete this key record?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This removes the record for <strong>{deleteTarget?.name}</strong> from the list. The
|
||||||
|
key is already revoked and cannot be used either way.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel className="border-zinc-700">Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-red-600 text-white hover:bg-red-500"
|
||||||
|
>
|
||||||
|
Delete record
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { hashApiKey, type ApiScope } from "@/lib/api-keys";
|
||||||
|
import { apiError } from "@/lib/api-response";
|
||||||
|
|
||||||
|
export type AuthenticatedKey = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
scopes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Only touch `lastUsedAt` once a minute per key, to keep reads read-mostly. */
|
||||||
|
const LAST_USED_THROTTLE_MS = 60_000;
|
||||||
|
|
||||||
|
const WWW_AUTHENTICATE = { "WWW-Authenticate": 'Bearer realm="xbid-api"' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the key out of `Authorization: Bearer <key>`, falling back to the
|
||||||
|
* `X-API-Key` header for clients that cannot set Authorization.
|
||||||
|
*/
|
||||||
|
function extractKey(req: NextRequest): string | null {
|
||||||
|
const header = req.headers.get("authorization");
|
||||||
|
if (header) {
|
||||||
|
const match = /^Bearer\s+(\S+)$/i.exec(header.trim());
|
||||||
|
if (match) return match[1];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const alt = req.headers.get("x-api-key");
|
||||||
|
return alt?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthResult =
|
||||||
|
| { ok: true; key: AuthenticatedKey }
|
||||||
|
| { ok: false; response: NextResponse };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate a request and check it carries `scope`.
|
||||||
|
*
|
||||||
|
* Returns a discriminated union rather than throwing so handlers read as
|
||||||
|
* `if (!auth.ok) return auth.response;`.
|
||||||
|
*/
|
||||||
|
export async function authenticateRequest(req: NextRequest, scope: ApiScope): Promise<AuthResult> {
|
||||||
|
const presented = extractKey(req);
|
||||||
|
|
||||||
|
if (!presented) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
response: apiError(
|
||||||
|
"unauthorized",
|
||||||
|
"Missing API key. Send `Authorization: Bearer <key>`.",
|
||||||
|
undefined,
|
||||||
|
WWW_AUTHENTICATE
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await prisma.apiKey.findUnique({
|
||||||
|
where: { hash: hashApiKey(presented) },
|
||||||
|
select: { id: true, name: true, scopes: true, revokedAt: true, expiresAt: true, lastUsedAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same message for unknown, revoked and expired keys: a caller holding a bad
|
||||||
|
// key learns only that it does not work.
|
||||||
|
if (!record || record.revokedAt || (record.expiresAt && record.expiresAt <= new Date())) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
response: apiError("unauthorized", "Invalid API key.", undefined, WWW_AUTHENTICATE),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!record.scopes.includes(scope)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
response: apiError("forbidden", `This API key does not have the \`${scope}\` scope.`, {
|
||||||
|
required: scope,
|
||||||
|
granted: record.scopes,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!record.lastUsedAt || Date.now() - record.lastUsedAt.getTime() > LAST_USED_THROTTLE_MS) {
|
||||||
|
// Best effort: a failed bookkeeping write must not fail the request.
|
||||||
|
await prisma.apiKey
|
||||||
|
.update({ where: { id: record.id }, data: { lastUsedAt: new Date() } })
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, key: { id: record.id, name: record.name, scopes: record.scopes } };
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
/** Every key starts with this so leaked keys are easy to grep for and revoke. */
|
||||||
|
export const API_KEY_PREFIX = "xbid_";
|
||||||
|
|
||||||
|
/** Number of leading characters stored in plain text for display purposes. */
|
||||||
|
export const API_KEY_DISPLAY_LENGTH = 16;
|
||||||
|
|
||||||
|
export const API_SCOPES = ["read", "write"] as const;
|
||||||
|
export type ApiScope = (typeof API_SCOPES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SHA-256 of the full key.
|
||||||
|
*
|
||||||
|
* Keys are 256 bits of CSPRNG output, so there is nothing to brute-force and a
|
||||||
|
* slow KDF (bcrypt/argon2) would only add latency to every API request. The
|
||||||
|
* hash is what lets us look a key up in one indexed query.
|
||||||
|
*/
|
||||||
|
export function hashApiKey(key: string): string {
|
||||||
|
return createHash("sha256").update(key.trim(), "utf8").digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a new API key. The plaintext `key` is returned once, for showing to the
|
||||||
|
* user, and is never persisted — only `prefix` and `hash` are stored.
|
||||||
|
*/
|
||||||
|
export function generateApiKey(): { key: string; prefix: string; hash: string } {
|
||||||
|
const key = `${API_KEY_PREFIX}${randomBytes(32).toString("base64url")}`;
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
prefix: key.slice(0, API_KEY_DISPLAY_LENGTH),
|
||||||
|
hash: hashApiKey(key),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { calcLineItem } from "@/lib/calculations";
|
||||||
|
import {
|
||||||
|
ShotNumberAllocator,
|
||||||
|
buildShotCode,
|
||||||
|
normalizeEpisode,
|
||||||
|
normalizeScene,
|
||||||
|
normalizeShowId,
|
||||||
|
} from "@/lib/shot-codes";
|
||||||
|
import { ApiProblem } from "@/lib/api-response";
|
||||||
|
import type {
|
||||||
|
ApiAssetCreateInput,
|
||||||
|
ApiLineItemInput,
|
||||||
|
ApiQuoteCreateInput,
|
||||||
|
ApiShotCreateInput,
|
||||||
|
} from "@/lib/api-validations";
|
||||||
|
|
||||||
|
type Tx = Prisma.TransactionClient;
|
||||||
|
|
||||||
|
/** Full quote graph, matching what the job detail page loads. */
|
||||||
|
export const quoteInclude = {
|
||||||
|
client: true,
|
||||||
|
shots: {
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
include: { lineItems: { orderBy: { sortOrder: "asc" }, include: { rateCard: true } } },
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
include: { lineItems: { orderBy: { sortOrder: "asc" }, include: { rateCard: true } } },
|
||||||
|
},
|
||||||
|
supervisions: {
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
include: { lineItems: { orderBy: { sortOrder: "asc" }, include: { rateCard: true } } },
|
||||||
|
},
|
||||||
|
previses: {
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
include: { lineItems: { orderBy: { sortOrder: "asc" }, include: { rateCard: true } } },
|
||||||
|
},
|
||||||
|
paymentSchedule: { include: { milestones: { orderBy: { sortOrder: "asc" } } } },
|
||||||
|
} satisfies Prisma.JobInclude;
|
||||||
|
|
||||||
|
export const shotInclude = {
|
||||||
|
lineItems: { orderBy: { sortOrder: "asc" }, include: { rateCard: true } },
|
||||||
|
} satisfies Prisma.ShotInclude;
|
||||||
|
|
||||||
|
export const assetInclude = {
|
||||||
|
lineItems: { orderBy: { sortOrder: "asc" }, include: { rateCard: true } },
|
||||||
|
} satisfies Prisma.AssetInclude;
|
||||||
|
|
||||||
|
// ─── Studio defaults ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type QuoteDefaults = {
|
||||||
|
currency: string;
|
||||||
|
markupPercent: Prisma.Decimal | number;
|
||||||
|
discountPercent: Prisma.Decimal | number;
|
||||||
|
taxPercent: Prisma.Decimal | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unspecified quote fields fall back to the studio's configured quote
|
||||||
|
* defaults, which is what those settings exist for, and to ZAR/0 when settings
|
||||||
|
* have never been saved.
|
||||||
|
*/
|
||||||
|
async function quoteDefaults(tx: Tx): Promise<QuoteDefaults> {
|
||||||
|
const settings = await tx.studioSettings.findUnique({ where: { id: "default" } });
|
||||||
|
return {
|
||||||
|
currency: settings?.defaultCurrency || "ZAR",
|
||||||
|
markupPercent: settings?.defaultMarkupPercent ?? 0,
|
||||||
|
discountPercent: settings?.defaultDiscountPercent ?? 0,
|
||||||
|
taxPercent: settings?.defaultTaxPercent ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Clients ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the quote's client from either an explicit `clientId` or an inline
|
||||||
|
* `client` object, which is matched on company name (case-insensitive) and
|
||||||
|
* created only when no existing client matches.
|
||||||
|
*/
|
||||||
|
export async function resolveClientId(
|
||||||
|
tx: Tx,
|
||||||
|
input: Pick<ApiQuoteCreateInput, "clientId" | "client">
|
||||||
|
): Promise<string> {
|
||||||
|
if (input.clientId) {
|
||||||
|
const existing = await tx.client.findUnique({
|
||||||
|
where: { id: input.clientId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
throw new ApiProblem("invalid_request", "No client exists with that clientId.", {
|
||||||
|
field: "clientId",
|
||||||
|
value: input.clientId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return existing.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ref = input.client;
|
||||||
|
if (!ref) {
|
||||||
|
throw new ApiProblem("invalid_request", "Provide either `clientId` or a `client` object.", {
|
||||||
|
field: "clientId",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = await tx.client.findFirst({
|
||||||
|
where: { companyName: { equals: ref.companyName, mode: "insensitive" } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (match) return match.id;
|
||||||
|
|
||||||
|
const created = await tx.client.create({
|
||||||
|
data: {
|
||||||
|
companyName: ref.companyName,
|
||||||
|
contactPerson: ref.contactPerson ?? null,
|
||||||
|
email: ref.email ?? null,
|
||||||
|
phone: ref.phone ?? null,
|
||||||
|
vatNumber: ref.vatNumber ?? null,
|
||||||
|
companyRegNumber: ref.companyRegNumber ?? null,
|
||||||
|
billingAddress: ref.billingAddress ?? null,
|
||||||
|
notes: ref.notes ?? null,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Project codes ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function slugifyCode(value: string): string {
|
||||||
|
return (
|
||||||
|
value
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[^A-Z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 24) || "QUOTE"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `projectCode` is unique across quotes. A caller-supplied code is used as-is
|
||||||
|
* (a clash surfaces as 409); a generated one gets `-2`, `-3`… appended until
|
||||||
|
* it is free.
|
||||||
|
*/
|
||||||
|
async function resolveProjectCode(tx: Tx, input: ApiQuoteCreateInput): Promise<string> {
|
||||||
|
if (input.projectCode) return input.projectCode;
|
||||||
|
|
||||||
|
const base = slugifyCode(
|
||||||
|
input.showId ? `${input.showId}-${input.projectTitle}` : input.projectTitle
|
||||||
|
);
|
||||||
|
|
||||||
|
const taken = await tx.job.findMany({
|
||||||
|
where: { projectCode: { startsWith: base } },
|
||||||
|
select: { projectCode: true },
|
||||||
|
});
|
||||||
|
const takenSet = new Set(taken.map((j) => j.projectCode));
|
||||||
|
if (!takenSet.has(base)) return base;
|
||||||
|
|
||||||
|
for (let n = 2; n < 1000; n++) {
|
||||||
|
const candidate = `${base}-${n}`;
|
||||||
|
if (!takenSet.has(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
throw new ApiProblem("conflict", "Could not derive a free projectCode. Supply one explicitly.", {
|
||||||
|
base,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Rate cards ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type RateCardRow = {
|
||||||
|
id: string;
|
||||||
|
defaultDailyRate: Prisma.Decimal;
|
||||||
|
hoursPerDay: Prisma.Decimal;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RateCardLookup = {
|
||||||
|
byId: Map<string, RateCardRow>;
|
||||||
|
byName: Map<string, RateCardRow>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch every rate card referenced anywhere in the request in one query, so a
|
||||||
|
* quote carrying hundreds of tasks does not issue hundreds of lookups. An
|
||||||
|
* unknown id or name is rejected rather than silently ignored.
|
||||||
|
*/
|
||||||
|
export async function loadRateCards(tx: Tx, items: ApiLineItemInput[]): Promise<RateCardLookup> {
|
||||||
|
const ids = [...new Set(items.map((i) => i.rateCardId).filter((v): v is string => Boolean(v)))];
|
||||||
|
const names = [
|
||||||
|
...new Set(items.map((i) => i.rateCardName).filter((v): v is string => Boolean(v))),
|
||||||
|
];
|
||||||
|
|
||||||
|
const byId: RateCardLookup["byId"] = new Map();
|
||||||
|
const byName: RateCardLookup["byName"] = new Map();
|
||||||
|
if (!ids.length && !names.length) return { byId, byName };
|
||||||
|
|
||||||
|
const cards = await tx.rateCard.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
...(ids.length ? [{ id: { in: ids } }] : []),
|
||||||
|
...(names.length ? [{ name: { in: names, mode: "insensitive" as const } }] : []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true, name: true, defaultDailyRate: true, hoursPerDay: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const card of cards) {
|
||||||
|
byId.set(card.id, card);
|
||||||
|
byName.set(card.name.toLowerCase(), card);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
if (!byId.has(id)) {
|
||||||
|
throw new ApiProblem("invalid_request", "No rate card exists with that rateCardId.", {
|
||||||
|
field: "rateCardId",
|
||||||
|
value: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const name of names) {
|
||||||
|
if (!byName.has(name.toLowerCase())) {
|
||||||
|
throw new ApiProblem("invalid_request", "No rate card exists with that rateCardName.", {
|
||||||
|
field: "rateCardName",
|
||||||
|
value: name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { byId, byName };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect every line item in a nested payload, for one rate-card query. */
|
||||||
|
export function collectLineItems(input: {
|
||||||
|
shots?: ApiShotCreateInput[];
|
||||||
|
assets?: ApiAssetCreateInput[];
|
||||||
|
}): ApiLineItemInput[] {
|
||||||
|
return [
|
||||||
|
...(input.shots ?? []).flatMap((s) => s.lineItems),
|
||||||
|
...(input.assets ?? []).flatMap((a) => a.lineItems),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Line items ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const num = (value: Prisma.Decimal | number | string) => Number(value.toString());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn an API line item into stored columns plus computed totals.
|
||||||
|
*
|
||||||
|
* `dailyRate` and `hoursPerDay` are inherited from the rate card when the
|
||||||
|
* caller omits them — the same substitution the line-item dialog makes when a
|
||||||
|
* rate card is picked in the UI.
|
||||||
|
*/
|
||||||
|
export function materializeLineItem(
|
||||||
|
input: ApiLineItemInput,
|
||||||
|
rateCards: RateCardLookup,
|
||||||
|
complexityMultiplier: string,
|
||||||
|
sortOrder: number
|
||||||
|
) {
|
||||||
|
const card = input.rateCardId
|
||||||
|
? rateCards.byId.get(input.rateCardId)
|
||||||
|
: input.rateCardName
|
||||||
|
? rateCards.byName.get(input.rateCardName.toLowerCase())
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const dailyRate = input.dailyRate ?? (card ? num(card.defaultDailyRate) : 0);
|
||||||
|
const hoursPerDay = input.hoursPerDay ?? (card ? num(card.hoursPerDay) : 8);
|
||||||
|
const numArtists = input.numArtists ?? 1;
|
||||||
|
const numDays = input.numDays ?? 0;
|
||||||
|
const quantityHours = input.quantityHours ?? 0;
|
||||||
|
const markupPercent = input.markupPercent ?? 0;
|
||||||
|
const discountPercent = input.discountPercent ?? 0;
|
||||||
|
const taxPercent = input.taxPercent ?? 0;
|
||||||
|
|
||||||
|
const calc = calcLineItem(
|
||||||
|
{
|
||||||
|
quantityHours,
|
||||||
|
dailyRate,
|
||||||
|
hoursPerDay,
|
||||||
|
numArtists,
|
||||||
|
numDays,
|
||||||
|
manualHourlyRate: input.manualHourlyRate ?? null,
|
||||||
|
manualSubtotal: input.manualSubtotal ?? null,
|
||||||
|
markupPercent,
|
||||||
|
discountPercent,
|
||||||
|
taxPercent,
|
||||||
|
},
|
||||||
|
complexityMultiplier
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
taskName: input.taskName,
|
||||||
|
artistRole: input.artistRole ?? null,
|
||||||
|
rateCardId: card?.id ?? null,
|
||||||
|
quantityHours,
|
||||||
|
dailyRate,
|
||||||
|
hoursPerDay,
|
||||||
|
numArtists,
|
||||||
|
numDays,
|
||||||
|
manualHourlyRate: input.manualHourlyRate ?? null,
|
||||||
|
manualSubtotal: input.manualSubtotal ?? null,
|
||||||
|
markupPercent,
|
||||||
|
discountPercent,
|
||||||
|
taxPercent,
|
||||||
|
notes: input.notes ?? null,
|
||||||
|
sortOrder,
|
||||||
|
hourlyRate: calc.hourlyRate.toFixed(4),
|
||||||
|
subtotal: calc.subtotal.toFixed(4),
|
||||||
|
markupAmount: calc.markupAmount.toFixed(4),
|
||||||
|
discountAmount: calc.discountAmount.toFixed(4),
|
||||||
|
taxAmount: calc.taxAmount.toFixed(4),
|
||||||
|
total: calc.total.toFixed(4),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shots ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ShotCodeJob = { showId: string | null; jobType: "STANDARD" | "EPISODIC" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the nested-create payload for a batch of shots, allocating numbers and
|
||||||
|
* codes from one in-memory allocator so a batch lands as 0010, 0020, 0030…
|
||||||
|
* within each scene.
|
||||||
|
*/
|
||||||
|
export function buildShotCreateData(
|
||||||
|
job: ShotCodeJob,
|
||||||
|
shots: ApiShotCreateInput[],
|
||||||
|
rateCards: RateCardLookup,
|
||||||
|
allocator: ShotNumberAllocator,
|
||||||
|
startSortOrder: number,
|
||||||
|
seenCodes: Set<string>
|
||||||
|
) {
|
||||||
|
return shots.map((shot, index) => {
|
||||||
|
const scene = normalizeScene(shot.scene);
|
||||||
|
const episode = normalizeEpisode(shot.episode);
|
||||||
|
const shotNumber = allocator.next(scene, episode);
|
||||||
|
const complexity = (shot.complexityMultiplier ?? 1).toString();
|
||||||
|
|
||||||
|
const shotCode =
|
||||||
|
shot.shotCode?.trim().toUpperCase() || buildShotCode(job, scene, episode, shotNumber);
|
||||||
|
if (seenCodes.has(shotCode)) {
|
||||||
|
throw new ApiProblem("conflict", "Duplicate shot code for this quote.", { shotCode });
|
||||||
|
}
|
||||||
|
seenCodes.add(shotCode);
|
||||||
|
|
||||||
|
return {
|
||||||
|
shotCode,
|
||||||
|
shotNumber,
|
||||||
|
scene,
|
||||||
|
episode,
|
||||||
|
shotGroup: shot.shotGroup ?? null,
|
||||||
|
description: shot.description ?? null,
|
||||||
|
durationSeconds: shot.durationSeconds ?? null,
|
||||||
|
complexityMultiplier: complexity,
|
||||||
|
notes: shot.notes ?? null,
|
||||||
|
sortOrder: startSortOrder + index,
|
||||||
|
lineItems: {
|
||||||
|
create: shot.lineItems.map((li, i) => materializeLineItem(li, rateCards, complexity, i)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed an allocator, sort order and code set from a quote's existing shots. */
|
||||||
|
export async function shotAllocatorForJob(tx: Tx, jobId: string, job: ShotCodeJob) {
|
||||||
|
const existing = await tx.shot.findMany({
|
||||||
|
where: { jobId },
|
||||||
|
select: { scene: true, episode: true, shotNumber: true, shotCode: true },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
allocator: new ShotNumberAllocator(job, existing),
|
||||||
|
sortOrder: existing.length,
|
||||||
|
codes: new Set(existing.map((s) => s.shotCode)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Assets ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function buildAssetCreateData(
|
||||||
|
assets: ApiAssetCreateInput[],
|
||||||
|
rateCards: RateCardLookup,
|
||||||
|
startSortOrder: number
|
||||||
|
) {
|
||||||
|
return assets.map((asset, index) => {
|
||||||
|
const complexity = (asset.complexityMultiplier ?? 1).toString();
|
||||||
|
return {
|
||||||
|
assetName: asset.assetName,
|
||||||
|
assetType: asset.assetType ?? null,
|
||||||
|
description: asset.description ?? null,
|
||||||
|
complexityMultiplier: complexity,
|
||||||
|
notes: asset.notes ?? null,
|
||||||
|
sortOrder: startSortOrder + index,
|
||||||
|
lineItems: {
|
||||||
|
create: asset.lineItems.map((li, i) => materializeLineItem(li, rateCards, complexity, i)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Quote creation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a quote and, optionally, its whole shot/asset/task tree in one
|
||||||
|
* transaction, so a payload that fails part-way leaves nothing behind.
|
||||||
|
*/
|
||||||
|
export async function createQuoteFromApi(input: ApiQuoteCreateInput) {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const defaults = await quoteDefaults(tx);
|
||||||
|
const clientId = await resolveClientId(tx, input);
|
||||||
|
const projectCode = await resolveProjectCode(tx, input);
|
||||||
|
|
||||||
|
const jobType = input.jobType ?? "STANDARD";
|
||||||
|
const showId = normalizeShowId(input.showId ?? slugifyCode(projectCode).slice(0, 10));
|
||||||
|
const job = { showId, jobType };
|
||||||
|
|
||||||
|
const rateCards = await loadRateCards(tx, collectLineItems(input));
|
||||||
|
const allocator = new ShotNumberAllocator(job);
|
||||||
|
|
||||||
|
return tx.job.create({
|
||||||
|
data: {
|
||||||
|
projectTitle: input.projectTitle,
|
||||||
|
projectCode,
|
||||||
|
showId,
|
||||||
|
jobType,
|
||||||
|
clientId,
|
||||||
|
status: input.status ?? "DRAFT",
|
||||||
|
currency: input.currency ?? defaults.currency,
|
||||||
|
issueDate: input.issueDate ?? new Date(),
|
||||||
|
expiryDate: input.expiryDate ?? null,
|
||||||
|
startDate: input.startDate ?? null,
|
||||||
|
estimatedCompletionDate: input.estimatedCompletionDate ?? null,
|
||||||
|
description: input.description ?? null,
|
||||||
|
internalNotes: input.internalNotes ?? null,
|
||||||
|
termsAndConditions: input.termsAndConditions ?? null,
|
||||||
|
customHeaderHtml: input.customHeaderHtml ?? null,
|
||||||
|
customFooterHtml: input.customFooterHtml ?? null,
|
||||||
|
markupPercent: input.markupPercent ?? defaults.markupPercent,
|
||||||
|
taxPercent: input.taxPercent ?? defaults.taxPercent,
|
||||||
|
discountPercent: input.discountPercent ?? defaults.discountPercent,
|
||||||
|
productionFeePercent: input.productionFeePercent ?? 0,
|
||||||
|
pullManagementFeePercent: input.pullManagementFeePercent ?? 0,
|
||||||
|
shots: {
|
||||||
|
create: buildShotCreateData(job, input.shots ?? [], rateCards, allocator, 0, new Set()),
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
create: buildAssetCreateData(input.assets ?? [], rateCards, 0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: quoteInclude,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared response shape for the v1 API.
|
||||||
|
*
|
||||||
|
* Success: { "data": … } plus { "meta": … } on list endpoints
|
||||||
|
* Failure: { "error": { "code", "message", "details"? } }
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ApiErrorCode =
|
||||||
|
| "unauthorized"
|
||||||
|
| "forbidden"
|
||||||
|
| "not_found"
|
||||||
|
| "invalid_request"
|
||||||
|
| "validation_failed"
|
||||||
|
| "conflict"
|
||||||
|
| "method_not_allowed"
|
||||||
|
| "internal_error";
|
||||||
|
|
||||||
|
const STATUS_FOR: Record<ApiErrorCode, number> = {
|
||||||
|
unauthorized: 401,
|
||||||
|
forbidden: 403,
|
||||||
|
not_found: 404,
|
||||||
|
invalid_request: 400,
|
||||||
|
validation_failed: 422,
|
||||||
|
conflict: 409,
|
||||||
|
method_not_allowed: 405,
|
||||||
|
internal_error: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prisma `Decimal` has a `toJSON` that emits a string, and `Date` emits ISO
|
||||||
|
* 8601, so a stringify/parse round-trip gives us JSON-safe output without
|
||||||
|
* hand-walking every model. Money and other decimals are therefore strings in
|
||||||
|
* API responses — callers keep full precision instead of inheriting float drift.
|
||||||
|
*/
|
||||||
|
export function serialize<T>(value: T): unknown {
|
||||||
|
return JSON.parse(JSON.stringify(value ?? null));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiOk(data: unknown, meta?: Record<string, unknown>, status = 200): NextResponse {
|
||||||
|
return NextResponse.json(
|
||||||
|
meta === undefined ? { data: serialize(data) } : { data: serialize(data), meta: serialize(meta) },
|
||||||
|
{ status, headers: { "Cache-Control": "no-store" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiCreated(data: unknown): NextResponse {
|
||||||
|
return apiOk(data, undefined, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiError(
|
||||||
|
code: ApiErrorCode,
|
||||||
|
message: string,
|
||||||
|
details?: unknown,
|
||||||
|
headers?: Record<string, string>
|
||||||
|
): NextResponse {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { code, message, ...(details === undefined ? {} : { details: serialize(details) }) } },
|
||||||
|
{
|
||||||
|
status: STATUS_FOR[code],
|
||||||
|
headers: { "Cache-Control": "no-store", ...headers },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flatten a ZodError into `{ "field.path": ["message", …] }`. */
|
||||||
|
function zodDetails(error: z.ZodError): Record<string, string[]> {
|
||||||
|
const out: Record<string, string[]> = {};
|
||||||
|
for (const issue of error.issues) {
|
||||||
|
const key = issue.path.length ? issue.path.join(".") : "_";
|
||||||
|
(out[key] ??= []).push(issue.message);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate a thrown error into a well-formed API response, so a failure
|
||||||
|
* never reaches a machine client as Next.js's HTML error page. Applied to
|
||||||
|
* every v1 route by `apiRoute`.
|
||||||
|
*/
|
||||||
|
export function toApiError(err: unknown): NextResponse {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
return apiError("validation_failed", "Request body failed validation.", zodDetails(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof ApiProblem) {
|
||||||
|
return apiError(err.code, err.message, err.details);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
if (err.code === "P2002") {
|
||||||
|
const target = err.meta?.target;
|
||||||
|
const fields = Array.isArray(target) ? target.join(", ") : String(target ?? "field");
|
||||||
|
return apiError("conflict", `A record with that ${fields} already exists.`, { fields: target });
|
||||||
|
}
|
||||||
|
if (err.code === "P2025") {
|
||||||
|
return apiError("not_found", "The requested record does not exist.");
|
||||||
|
}
|
||||||
|
if (err.code === "P2003") {
|
||||||
|
return apiError("invalid_request", "A referenced record does not exist.", { field: err.meta?.field_name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof SyntaxError) {
|
||||||
|
return apiError("invalid_request", "Request body is not valid JSON.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("[api] unhandled error", err);
|
||||||
|
return apiError("internal_error", "Something went wrong handling the request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throw from anywhere inside a handler to return a specific API error. */
|
||||||
|
export class ApiProblem extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: ApiErrorCode,
|
||||||
|
message: string,
|
||||||
|
readonly details?: unknown
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiProblem";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a JSON request body, rejecting anything that is not a JSON object. */
|
||||||
|
export async function readJsonBody(req: Request): Promise<unknown> {
|
||||||
|
const text = await req.text();
|
||||||
|
if (!text.trim()) {
|
||||||
|
throw new ApiProblem("invalid_request", "Request body is required.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new ApiProblem("invalid_request", "Request body is not valid JSON.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { authenticateRequest, type AuthenticatedKey } from "@/lib/api-auth";
|
||||||
|
import type { ApiScope } from "@/lib/api-keys";
|
||||||
|
import { ApiProblem, readJsonBody, toApiError } from "@/lib/api-response";
|
||||||
|
|
||||||
|
/** Dynamic route params, which are a promise in Next 16. */
|
||||||
|
export type ApiContext<P> = { params: Promise<P> };
|
||||||
|
export type IdContext = ApiContext<{ id: string }>;
|
||||||
|
|
||||||
|
type Handler<Ctx> = (
|
||||||
|
req: NextRequest,
|
||||||
|
ctx: Ctx,
|
||||||
|
key: AuthenticatedKey
|
||||||
|
) => Promise<NextResponse>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a route handler with API-key authentication and error translation, so
|
||||||
|
* each handler can get on with its own job.
|
||||||
|
*
|
||||||
|
* `scope` is the permission the key must carry: `read` for GET, `write` for
|
||||||
|
* anything that mutates.
|
||||||
|
*/
|
||||||
|
export function apiRoute<Ctx>(scope: ApiScope, handler: Handler<Ctx>) {
|
||||||
|
return async (req: NextRequest, ctx: Ctx): Promise<NextResponse> => {
|
||||||
|
try {
|
||||||
|
const auth = await authenticateRequest(req, scope);
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
return await handler(req, ctx, auth.key);
|
||||||
|
} catch (err) {
|
||||||
|
return toApiError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept either a single object or an array of them at the same endpoint, and
|
||||||
|
* remember which was sent so the response can mirror the request's shape.
|
||||||
|
*/
|
||||||
|
export async function parseOneOrMany<S extends z.ZodType>(
|
||||||
|
schema: S,
|
||||||
|
req: NextRequest
|
||||||
|
): Promise<{ items: z.infer<S>[]; wasArray: boolean }> {
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
|
||||||
|
if (Array.isArray(body)) {
|
||||||
|
if (body.length === 0) {
|
||||||
|
throw new ApiProblem("invalid_request", "Request array must not be empty.");
|
||||||
|
}
|
||||||
|
if (body.length > 500) {
|
||||||
|
throw new ApiProblem("invalid_request", "Send at most 500 items per request.", {
|
||||||
|
received: body.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { items: z.array(schema).parse(body), wasArray: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items: [schema.parse(body)], wasArray: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse `?limit=&offset=&…` from the URL with a Zod schema. */
|
||||||
|
export function parseQuery<S extends z.ZodType>(schema: S, req: NextRequest): z.infer<S> {
|
||||||
|
return schema.parse(Object.fromEntries(req.nextUrl.searchParams));
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input schemas for the v1 API.
|
||||||
|
*
|
||||||
|
* These are deliberately looser than the form schemas in `validations.ts`:
|
||||||
|
* fields a caller can reasonably omit are optional here, and the service layer
|
||||||
|
* fills them from the rate card, the studio settings or a sensible default.
|
||||||
|
* Leaving them `undefined` (rather than defaulted here) is what lets the
|
||||||
|
* service tell "not supplied" apart from an explicit zero.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Accepts `2026-03-01`, a full ISO 8601 timestamp, or epoch milliseconds. */
|
||||||
|
const dateInput = z.union([z.string(), z.number(), z.date()]).transform((value, ctx) => {
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
ctx.addIssue({ code: "custom", message: "Expected an ISO 8601 date, e.g. 2026-03-01" });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return date;
|
||||||
|
});
|
||||||
|
|
||||||
|
const percent = z.coerce.number().min(0).max(100);
|
||||||
|
const money = z.coerce.number().min(0);
|
||||||
|
const optionalText = z.string().trim().optional();
|
||||||
|
|
||||||
|
// ─── Line items (tasks) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const apiLineItemSchema = z.object({
|
||||||
|
taskName: z.string().trim().min(1, "taskName is required"),
|
||||||
|
artistRole: optionalText,
|
||||||
|
/** Either id or name; name is matched case-insensitively against active cards. */
|
||||||
|
rateCardId: optionalText,
|
||||||
|
rateCardName: optionalText,
|
||||||
|
quantityHours: money.optional(),
|
||||||
|
dailyRate: money.optional(),
|
||||||
|
hoursPerDay: z.coerce.number().min(1).max(24).optional(),
|
||||||
|
numArtists: z.coerce.number().min(0).optional(),
|
||||||
|
numDays: money.optional(),
|
||||||
|
manualHourlyRate: money.nullish(),
|
||||||
|
manualSubtotal: money.nullish(),
|
||||||
|
markupPercent: percent.optional(),
|
||||||
|
discountPercent: percent.optional(),
|
||||||
|
taxPercent: percent.optional(),
|
||||||
|
notes: optionalText,
|
||||||
|
});
|
||||||
|
export type ApiLineItemInput = z.infer<typeof apiLineItemSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `lineItems` is canonical; `tasks` is accepted as an alias because that is
|
||||||
|
* what these rows are called in the UI and in the CSV export.
|
||||||
|
*/
|
||||||
|
const lineItemsField = z
|
||||||
|
.object({
|
||||||
|
lineItems: z.array(apiLineItemSchema).optional(),
|
||||||
|
tasks: z.array(apiLineItemSchema).optional(),
|
||||||
|
})
|
||||||
|
.transform(({ lineItems, tasks }) => ({ lineItems: lineItems ?? tasks ?? [] }));
|
||||||
|
|
||||||
|
// ─── Shots ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const shotFields = z.object({
|
||||||
|
/** Supply to preserve codes from an external system; generated when omitted. */
|
||||||
|
shotCode: optionalText,
|
||||||
|
scene: optionalText,
|
||||||
|
episode: optionalText,
|
||||||
|
shotGroup: optionalText,
|
||||||
|
description: optionalText,
|
||||||
|
durationSeconds: z.coerce.number().int().min(0).nullish(),
|
||||||
|
complexityMultiplier: z.coerce.number().min(0.1).max(10).optional(),
|
||||||
|
notes: optionalText,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiShotCreateSchema = shotFields.and(lineItemsField);
|
||||||
|
export type ApiShotCreateInput = z.infer<typeof apiShotCreateSchema>;
|
||||||
|
|
||||||
|
export const apiShotUpdateSchema = shotFields.extend({
|
||||||
|
shotNumber: z.coerce.number().int().min(0).optional(),
|
||||||
|
sortOrder: z.coerce.number().int().min(0).optional(),
|
||||||
|
});
|
||||||
|
export type ApiShotUpdateInput = z.infer<typeof apiShotUpdateSchema>;
|
||||||
|
|
||||||
|
// ─── Assets ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const assetFields = z.object({
|
||||||
|
assetName: z.string().trim().min(1, "assetName is required"),
|
||||||
|
assetType: optionalText,
|
||||||
|
description: optionalText,
|
||||||
|
complexityMultiplier: z.coerce.number().min(0.1).max(10).optional(),
|
||||||
|
notes: optionalText,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiAssetCreateSchema = assetFields.and(lineItemsField);
|
||||||
|
export type ApiAssetCreateInput = z.infer<typeof apiAssetCreateSchema>;
|
||||||
|
|
||||||
|
export const apiAssetUpdateSchema = assetFields.partial().extend({
|
||||||
|
sortOrder: z.coerce.number().int().min(0).optional(),
|
||||||
|
});
|
||||||
|
export type ApiAssetUpdateInput = z.infer<typeof apiAssetUpdateSchema>;
|
||||||
|
|
||||||
|
// ─── Quotes (jobs) ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Inline client: matched on `companyName` (case-insensitive), created if new. */
|
||||||
|
export const apiClientRefSchema = z.object({
|
||||||
|
companyName: z.string().trim().min(1, "client.companyName is required"),
|
||||||
|
contactPerson: optionalText,
|
||||||
|
email: z.string().trim().email("client.email must be a valid email").optional(),
|
||||||
|
phone: optionalText,
|
||||||
|
vatNumber: optionalText,
|
||||||
|
companyRegNumber: optionalText,
|
||||||
|
billingAddress: optionalText,
|
||||||
|
notes: optionalText,
|
||||||
|
});
|
||||||
|
|
||||||
|
const quoteFields = z.object({
|
||||||
|
projectTitle: z.string().trim().min(1, "projectTitle is required"),
|
||||||
|
/** Must be unique across all quotes; derived from the title when omitted. */
|
||||||
|
projectCode: optionalText,
|
||||||
|
showId: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(10, "showId must be 10 characters or fewer")
|
||||||
|
.regex(/^[A-Z0-9_]+$/i, "showId may contain only letters, numbers and underscores")
|
||||||
|
.optional(),
|
||||||
|
jobType: z.enum(["STANDARD", "EPISODIC"]).optional(),
|
||||||
|
status: z.enum(["DRAFT", "SENT", "APPROVED", "REJECTED", "ARCHIVED"]).optional(),
|
||||||
|
currency: z.string().trim().min(1).max(8).optional(),
|
||||||
|
issueDate: dateInput.optional(),
|
||||||
|
expiryDate: dateInput.nullish(),
|
||||||
|
startDate: dateInput.nullish(),
|
||||||
|
estimatedCompletionDate: dateInput.nullish(),
|
||||||
|
description: optionalText,
|
||||||
|
internalNotes: optionalText,
|
||||||
|
termsAndConditions: optionalText,
|
||||||
|
customHeaderHtml: optionalText,
|
||||||
|
customFooterHtml: optionalText,
|
||||||
|
markupPercent: percent.optional(),
|
||||||
|
taxPercent: percent.optional(),
|
||||||
|
discountPercent: percent.optional(),
|
||||||
|
productionFeePercent: percent.optional(),
|
||||||
|
pullManagementFeePercent: percent.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiQuoteCreateSchema = quoteFields
|
||||||
|
.extend({
|
||||||
|
clientId: optionalText,
|
||||||
|
client: apiClientRefSchema.optional(),
|
||||||
|
shots: z.array(apiShotCreateSchema).optional(),
|
||||||
|
assets: z.array(apiAssetCreateSchema).optional(),
|
||||||
|
})
|
||||||
|
.refine((v) => Boolean(v.clientId) || Boolean(v.client), {
|
||||||
|
message: "Provide either `clientId` or a `client` object",
|
||||||
|
path: ["clientId"],
|
||||||
|
});
|
||||||
|
export type ApiQuoteCreateInput = z.infer<typeof apiQuoteCreateSchema>;
|
||||||
|
|
||||||
|
export const apiQuoteUpdateSchema = quoteFields.partial().extend({
|
||||||
|
clientId: optionalText,
|
||||||
|
});
|
||||||
|
export type ApiQuoteUpdateInput = z.infer<typeof apiQuoteUpdateSchema>;
|
||||||
|
|
||||||
|
// ─── List query parameters ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const apiListQuerySchema = z.object({
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(25),
|
||||||
|
offset: z.coerce.number().int().min(0).default(0),
|
||||||
|
status: z.enum(["DRAFT", "SENT", "APPROVED", "REJECTED", "ARCHIVED"]).optional(),
|
||||||
|
clientId: optionalText,
|
||||||
|
search: optionalText,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── API key management (used by the settings UI) ─────────────────────────────
|
||||||
|
|
||||||
|
export const apiKeyCreateSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, "Name is required").max(80, "Max 80 characters"),
|
||||||
|
scopes: z.array(z.enum(["read", "write"])).min(1, "Pick at least one scope").default(["read", "write"]),
|
||||||
|
expiresAt: dateInput.nullish(),
|
||||||
|
});
|
||||||
|
export type ApiKeyCreateInput = z.infer<typeof apiKeyCreateSchema>;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Shot numbering and shot-code construction.
|
||||||
|
*
|
||||||
|
* Extracted so the UI (`createShot`) and the v1 API build identical codes
|
||||||
|
* instead of each carrying its own copy of the rule.
|
||||||
|
*
|
||||||
|
* Codes look like `SHOW_SCENE_0010`, or `SHOW_EPISODE_SCENE_0010` for episodic
|
||||||
|
* jobs. Numbers step by 10 within a scene (and within an episode, when
|
||||||
|
* episodic) so shots can be inserted between existing ones later.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SHOT_NUMBER_STEP = 10;
|
||||||
|
const SHOT_NUMBER_PAD = 4;
|
||||||
|
|
||||||
|
export type ShotCodeJob = {
|
||||||
|
showId: string | null;
|
||||||
|
jobType: "STANDARD" | "EPISODIC";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scene and episode are upper-cased on the way in. The generated code is
|
||||||
|
* upper-case regardless, so normalising the stored values too keeps the
|
||||||
|
* per-scene numbering lookup from missing differently-cased duplicates.
|
||||||
|
*/
|
||||||
|
export function normalizeScene(scene: string | null | undefined): string {
|
||||||
|
const trimmed = (scene ?? "").trim();
|
||||||
|
return (trimmed || "SC").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEpisode(episode: string | null | undefined): string | null {
|
||||||
|
const trimmed = (episode ?? "").trim();
|
||||||
|
return trimmed ? trimmed.toUpperCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeShowId(showId: string | null | undefined): string {
|
||||||
|
const trimmed = (showId ?? "").trim();
|
||||||
|
return (trimmed || "UNK").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildShotCode(
|
||||||
|
job: ShotCodeJob,
|
||||||
|
scene: string | null | undefined,
|
||||||
|
episode: string | null | undefined,
|
||||||
|
shotNumber: number
|
||||||
|
): string {
|
||||||
|
const show = normalizeShowId(job.showId);
|
||||||
|
const sceneKey = normalizeScene(scene);
|
||||||
|
const episodeKey = normalizeEpisode(episode);
|
||||||
|
const padded = String(shotNumber).padStart(SHOT_NUMBER_PAD, "0");
|
||||||
|
|
||||||
|
return job.jobType === "EPISODIC" && episodeKey
|
||||||
|
? `${show}_${episodeKey}_${sceneKey}_${padded}`
|
||||||
|
: `${show}_${sceneKey}_${padded}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shots are numbered per scene, and per episode as well on episodic jobs. */
|
||||||
|
export function shotGroupKey(
|
||||||
|
job: ShotCodeJob,
|
||||||
|
scene: string | null | undefined,
|
||||||
|
episode: string | null | undefined
|
||||||
|
): string {
|
||||||
|
const sceneKey = normalizeScene(scene);
|
||||||
|
return job.jobType === "EPISODIC" ? `${normalizeEpisode(episode) ?? ""}::${sceneKey}` : sceneKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands out shot numbers for a batch without re-querying per shot.
|
||||||
|
*
|
||||||
|
* Seed it with the highest number already used in each scene/episode group,
|
||||||
|
* then call `next()` per shot; it keeps its own running maximum so a request
|
||||||
|
* creating many shots in one scene still numbers them 10, 20, 30, ….
|
||||||
|
*/
|
||||||
|
export class ShotNumberAllocator {
|
||||||
|
private readonly maxByGroup: Map<string, number>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly job: ShotCodeJob,
|
||||||
|
seed: Iterable<{ scene: string | null; episode: string | null; shotNumber: number }> = []
|
||||||
|
) {
|
||||||
|
this.maxByGroup = new Map();
|
||||||
|
for (const row of seed) {
|
||||||
|
const key = shotGroupKey(job, row.scene, row.episode);
|
||||||
|
const current = this.maxByGroup.get(key) ?? 0;
|
||||||
|
if (row.shotNumber > current) this.maxByGroup.set(key, row.shotNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next(scene: string | null | undefined, episode: string | null | undefined): number {
|
||||||
|
const key = shotGroupKey(this.job, scene, episode);
|
||||||
|
const shotNumber = (this.maxByGroup.get(key) ?? 0) + SHOT_NUMBER_STEP;
|
||||||
|
this.maxByGroup.set(key, shotNumber);
|
||||||
|
return shotNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-1
@@ -7,8 +7,15 @@ const PUBLIC_PATHS = ["/login"];
|
|||||||
export default async function proxy(request: NextRequest) {
|
export default async function proxy(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
// `/api/v1` is exempt from the session check because it authenticates with
|
||||||
|
// an API key instead of a cookie: redirecting a machine client to /login
|
||||||
|
// would turn every unauthenticated call into a 307 pointing at an HTML page.
|
||||||
|
// It is not unprotected — every v1 route handler is wrapped in `apiRoute()`,
|
||||||
|
// which requires a live, unrevoked key carrying the right scope.
|
||||||
|
const isApiV1 = pathname === "/api/v1" || pathname.startsWith("/api/v1/");
|
||||||
|
|
||||||
const isPublic =
|
const isPublic =
|
||||||
PUBLIC_PATHS.includes(pathname) || pathname.startsWith("/api/auth");
|
PUBLIC_PATHS.includes(pathname) || pathname.startsWith("/api/auth") || isApiV1;
|
||||||
|
|
||||||
const token = await getToken({
|
const token = await getToken({
|
||||||
req: request,
|
req: request,
|
||||||
|
|||||||
Reference in New Issue
Block a user