Add REST API for quotes, shots, assets and tasks

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

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

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

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

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

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

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

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

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