Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 396ebf9837 | |||
| 8a93a18bb9 |
@@ -20,6 +20,17 @@ jobs:
|
||||
|
||||
git pull
|
||||
|
||||
docker compose up -d --build
|
||||
# Build both images up front. `migrate` targets the Dockerfile's
|
||||
# builder stage, which `app` also builds through, so the layers are
|
||||
# shared and this costs no extra build time. The profile is named
|
||||
# explicitly rather than relying on a service name activating it.
|
||||
docker compose --profile tools build app migrate
|
||||
|
||||
# Apply pending migrations before the new app serves traffic. This
|
||||
# exits non-zero if a migration fails, failing the deploy rather than
|
||||
# starting an app whose schema is behind its code.
|
||||
docker compose --profile tools run --rm migrate
|
||||
|
||||
docker compose up -d
|
||||
|
||||
docker image prune -f
|
||||
|
||||
@@ -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
|
||||
- **PDF Export** — professional quote PDF with front page + detailed appendix
|
||||
- **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))
|
||||
|
||||
---
|
||||
|
||||
@@ -111,11 +112,21 @@ Open http://localhost:3000
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
After first run, apply migrations:
|
||||
Pushing to `main` triggers the Gitea workflow in `.gitea/workflows/deploy.yml`,
|
||||
which pulls, builds, **applies pending migrations, and only then starts the app**.
|
||||
A failed migration fails the deploy rather than starting an app whose schema is
|
||||
behind its code, so there is no manual migration step.
|
||||
|
||||
Migrations run through the `migrate` service, which builds from the Dockerfile
|
||||
`builder` stage. That matters: the production image ships runtime dependencies
|
||||
only, and the Prisma CLI is a devDependency, so `docker compose exec app npx
|
||||
prisma ...` would try to download Prisma inside the container at runtime.
|
||||
|
||||
To run migrations by hand (or to seed on a first deploy):
|
||||
|
||||
```bash
|
||||
docker compose exec app npx prisma migrate deploy
|
||||
docker compose exec app npx prisma db seed
|
||||
docker compose --profile tools run --rm migrate
|
||||
docker compose --profile tools run --rm migrate npx prisma db seed
|
||||
```
|
||||
|
||||
---
|
||||
@@ -130,6 +141,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
|
||||
|
||||
```
|
||||
|
||||
@@ -21,6 +21,34 @@ services:
|
||||
networks:
|
||||
- xbid
|
||||
|
||||
# One-off migration runner, applied by the deploy workflow before the app
|
||||
# comes up. It builds from the Dockerfile's `builder` stage because the
|
||||
# production image ships runtime dependencies only -- the Prisma CLI and its
|
||||
# engines are devDependencies and are not in the standalone bundle.
|
||||
#
|
||||
# Behind a profile so `docker compose up` never starts it; the deploy targets
|
||||
# it explicitly with `docker compose run --rm migrate`.
|
||||
migrate:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: builder
|
||||
|
||||
environment:
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/xbid
|
||||
|
||||
command: ["npx", "prisma", "migrate", "deploy"]
|
||||
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
networks:
|
||||
- xbid
|
||||
|
||||
profiles:
|
||||
- tools
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
// ─── 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 { shotSchema, assetSchema, supervisionSchema, previsSchema, lineItemSchema, taskTemplateSchema } from "@/lib/validations";
|
||||
import { calcLineItem } from "@/lib/calculations";
|
||||
import {
|
||||
ShotNumberAllocator,
|
||||
buildShotCode,
|
||||
normalizeEpisode,
|
||||
normalizeScene,
|
||||
} from "@/lib/shot-codes";
|
||||
|
||||
// ─── External Shots API ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -147,34 +153,29 @@ export async function createShot(jobId: string, data: unknown) {
|
||||
});
|
||||
if (!job) throw new Error("Job not found");
|
||||
|
||||
// Auto-increment shot number scoped to this scene (and episode for episodic)
|
||||
const sceneKey = (validated.scene || "SC").toUpperCase();
|
||||
const episodeKey = validated.episode ? validated.episode.toUpperCase() : null;
|
||||
const agg = await prisma.shot.aggregate({
|
||||
// Numbering and code construction are shared with the v1 API so the UI and
|
||||
// API always produce identical shot codes.
|
||||
const scene = normalizeScene(validated.scene);
|
||||
const episode = normalizeEpisode(validated.episode);
|
||||
|
||||
const peers = await prisma.shot.findMany({
|
||||
where: {
|
||||
jobId,
|
||||
scene: sceneKey,
|
||||
...(job.jobType === "EPISODIC" ? { episode: episodeKey } : {}),
|
||||
scene,
|
||||
...(job.jobType === "EPISODIC" ? { episode } : {}),
|
||||
},
|
||||
_max: { shotNumber: true },
|
||||
select: { scene: true, episode: true, shotNumber: true },
|
||||
});
|
||||
const shotNumber = (agg._max.shotNumber ?? 0) + 10;
|
||||
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 shotNumber = new ShotNumberAllocator(job, peers).next(scene, episode);
|
||||
|
||||
const count = await prisma.shot.count({ where: { jobId } });
|
||||
const shot = await prisma.shot.create({
|
||||
data: {
|
||||
...validated,
|
||||
jobId,
|
||||
shotCode,
|
||||
scene,
|
||||
episode,
|
||||
shotCode: buildShotCode(job, scene, episode, shotNumber),
|
||||
shotNumber,
|
||||
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';
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { getStudioSettings } from "@/actions/settings";
|
||||
import { listApiKeys } from "@/actions/api-keys";
|
||||
import { SettingsForm } from "@/components/settings/settings-form";
|
||||
import { ApiKeysManager, type ApiKeyRow } from "@/components/settings/api-keys-manager";
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const rawSettings = await getStudioSettings();
|
||||
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 (
|
||||
<div className="p-6 max-w-4xl mx-auto space-y-6">
|
||||
<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">
|
||||
<SettingsForm initialData={settings} />
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6">
|
||||
<ApiKeysManager initialKeys={apiKeys} />
|
||||
</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) {
|
||||
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 =
|
||||
PUBLIC_PATHS.includes(pathname) || pathname.startsWith("/api/auth");
|
||||
PUBLIC_PATHS.includes(pathname) || pathname.startsWith("/api/auth") || isApiV1;
|
||||
|
||||
const token = await getToken({
|
||||
req: request,
|
||||
|
||||
Reference in New Issue
Block a user