From 8a93a18bb94e619e33e965cc879b2d128418de0d Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Wed, 11 Nov 2026 09:10:45 +0200 Subject: [PATCH] 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 --- API.md | 494 ++++++++++++++++++ README.md | 23 + .../20261111000000_add_api_keys/migration.sql | 25 + prisma/schema.prisma | 21 + src/actions/api-keys.ts | 104 ++++ src/actions/shots-assets.ts | 37 +- .../api/v1/assets/[id]/line-items/route.ts | 63 +++ src/app/api/v1/assets/[id]/route.ts | 58 ++ src/app/api/v1/clients/route.ts | 41 ++ src/app/api/v1/jobs/[id]/assets/route.ts | 2 + src/app/api/v1/jobs/[id]/recalculate/route.ts | 2 + src/app/api/v1/jobs/[id]/route.ts | 2 + src/app/api/v1/jobs/[id]/shots/route.ts | 2 + src/app/api/v1/jobs/route.ts | 5 + src/app/api/v1/line-items/[id]/route.ts | 107 ++++ src/app/api/v1/quotes/[id]/assets/route.ts | 60 +++ .../api/v1/quotes/[id]/recalculate/route.ts | 34 ++ src/app/api/v1/quotes/[id]/route.ts | 78 +++ src/app/api/v1/quotes/[id]/shots/route.ts | 69 +++ src/app/api/v1/quotes/route.ts | 58 ++ src/app/api/v1/rate-cards/route.ts | 42 ++ src/app/api/v1/route.ts | 58 ++ src/app/api/v1/shots/[id]/line-items/route.ts | 65 +++ src/app/api/v1/shots/[id]/route.ts | 66 +++ src/app/settings/page.tsx | 16 + src/components/settings/api-keys-manager.tsx | 374 +++++++++++++ src/lib/api-auth.ts | 89 ++++ src/lib/api-keys.ts | 34 ++ src/lib/api-quotes.ts | 452 ++++++++++++++++ src/lib/api-response.ts | 138 +++++ src/lib/api-route.ts | 64 +++ src/lib/api-validations.ts | 179 +++++++ src/lib/shot-codes.ts | 94 ++++ src/proxy.ts | 9 +- 34 files changed, 2946 insertions(+), 19 deletions(-) create mode 100644 API.md create mode 100644 prisma/migrations/20261111000000_add_api_keys/migration.sql create mode 100644 src/actions/api-keys.ts create mode 100644 src/app/api/v1/assets/[id]/line-items/route.ts create mode 100644 src/app/api/v1/assets/[id]/route.ts create mode 100644 src/app/api/v1/clients/route.ts create mode 100644 src/app/api/v1/jobs/[id]/assets/route.ts create mode 100644 src/app/api/v1/jobs/[id]/recalculate/route.ts create mode 100644 src/app/api/v1/jobs/[id]/route.ts create mode 100644 src/app/api/v1/jobs/[id]/shots/route.ts create mode 100644 src/app/api/v1/jobs/route.ts create mode 100644 src/app/api/v1/line-items/[id]/route.ts create mode 100644 src/app/api/v1/quotes/[id]/assets/route.ts create mode 100644 src/app/api/v1/quotes/[id]/recalculate/route.ts create mode 100644 src/app/api/v1/quotes/[id]/route.ts create mode 100644 src/app/api/v1/quotes/[id]/shots/route.ts create mode 100644 src/app/api/v1/quotes/route.ts create mode 100644 src/app/api/v1/rate-cards/route.ts create mode 100644 src/app/api/v1/route.ts create mode 100644 src/app/api/v1/shots/[id]/line-items/route.ts create mode 100644 src/app/api/v1/shots/[id]/route.ts create mode 100644 src/components/settings/api-keys-manager.tsx create mode 100644 src/lib/api-auth.ts create mode 100644 src/lib/api-keys.ts create mode 100644 src/lib/api-quotes.ts create mode 100644 src/lib/api-response.ts create mode 100644 src/lib/api-route.ts create mode 100644 src/lib/api-validations.ts create mode 100644 src/lib/shot-codes.ts diff --git a/API.md b/API.md new file mode 100644 index 0000000..e892346 --- /dev/null +++ b/API.md @@ -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: ` 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. diff --git a/README.md b/README.md index ff0127d..2bb90d7 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/prisma/migrations/20261111000000_add_api_keys/migration.sql b/prisma/migrations/20261111000000_add_api_keys/migration.sql new file mode 100644 index 0000000..1c50c42 --- /dev/null +++ b/prisma/migrations/20261111000000_add_api_keys/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c5ee004..3200648 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/src/actions/api-keys.ts b/src/actions/api-keys.ts new file mode 100644 index 0000000..dff049b --- /dev/null +++ b/src/actions/api-keys.ts @@ -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 { + 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 }; +} diff --git a/src/actions/shots-assets.ts b/src/actions/shots-assets.ts index db75881..3f51f5f 100644 --- a/src/actions/shots-assets.ts +++ b/src/actions/shots-assets.ts @@ -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, }, diff --git a/src/app/api/v1/assets/[id]/line-items/route.ts b/src/app/api/v1/assets/[id]/line-items/route.ts new file mode 100644 index 0000000..dc9eeae --- /dev/null +++ b/src/app/api/v1/assets/[id]/line-items/route.ts @@ -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("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("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]); +}); diff --git a/src/app/api/v1/assets/[id]/route.ts b/src/app/api/v1/assets/[id]/route.ts new file mode 100644 index 0000000..6145ea3 --- /dev/null +++ b/src/app/api/v1/assets/[id]/route.ts @@ -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("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("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("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 }); +}); diff --git a/src/app/api/v1/clients/route.ts b/src/app/api/v1/clients/route.ts new file mode 100644 index 0000000..d986cb5 --- /dev/null +++ b/src/app/api/v1/clients/route.ts @@ -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("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 }); +}); diff --git a/src/app/api/v1/jobs/[id]/assets/route.ts b/src/app/api/v1/jobs/[id]/assets/route.ts new file mode 100644 index 0000000..287d410 --- /dev/null +++ b/src/app/api/v1/jobs/[id]/assets/route.ts @@ -0,0 +1,2 @@ +/** Alias for `/api/v1/quotes/{idOrCode}/assets`. */ +export { GET, POST } from "../../../quotes/[id]/assets/route"; diff --git a/src/app/api/v1/jobs/[id]/recalculate/route.ts b/src/app/api/v1/jobs/[id]/recalculate/route.ts new file mode 100644 index 0000000..aefc6f4 --- /dev/null +++ b/src/app/api/v1/jobs/[id]/recalculate/route.ts @@ -0,0 +1,2 @@ +/** Alias for `/api/v1/quotes/{idOrCode}/recalculate`. */ +export { POST } from "../../../quotes/[id]/recalculate/route"; diff --git a/src/app/api/v1/jobs/[id]/route.ts b/src/app/api/v1/jobs/[id]/route.ts new file mode 100644 index 0000000..3626379 --- /dev/null +++ b/src/app/api/v1/jobs/[id]/route.ts @@ -0,0 +1,2 @@ +/** Alias for `/api/v1/quotes/{idOrCode}`. */ +export { GET, PATCH, DELETE } from "../../quotes/[id]/route"; diff --git a/src/app/api/v1/jobs/[id]/shots/route.ts b/src/app/api/v1/jobs/[id]/shots/route.ts new file mode 100644 index 0000000..e611319 --- /dev/null +++ b/src/app/api/v1/jobs/[id]/shots/route.ts @@ -0,0 +1,2 @@ +/** Alias for `/api/v1/quotes/{idOrCode}/shots`. */ +export { GET, POST } from "../../../quotes/[id]/shots/route"; diff --git a/src/app/api/v1/jobs/route.ts b/src/app/api/v1/jobs/route.ts new file mode 100644 index 0000000..44c2176 --- /dev/null +++ b/src/app/api/v1/jobs/route.ts @@ -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"; diff --git a/src/app/api/v1/line-items/[id]/route.ts b/src/app/api/v1/line-items/[id]/route.ts new file mode 100644 index 0000000..1a8da98 --- /dev/null +++ b/src/app/api/v1/line-items/[id]/route.ts @@ -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("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("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("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 }); +}); diff --git a/src/app/api/v1/quotes/[id]/assets/route.ts b/src/app/api/v1/quotes/[id]/assets/route.ts new file mode 100644 index 0000000..a3a3470 --- /dev/null +++ b/src/app/api/v1/quotes/[id]/assets/route.ts @@ -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 { + 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("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("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]); +}); diff --git a/src/app/api/v1/quotes/[id]/recalculate/route.ts b/src/app/api/v1/quotes/[id]/recalculate/route.ts new file mode 100644 index 0000000..c5fa758 --- /dev/null +++ b/src/app/api/v1/quotes/[id]/recalculate/route.ts @@ -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("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); +}); diff --git a/src/app/api/v1/quotes/[id]/route.ts b/src/app/api/v1/quotes/[id]/route.ts new file mode 100644 index 0000000..7ff58fc --- /dev/null +++ b/src/app/api/v1/quotes/[id]/route.ts @@ -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 { + 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("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("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("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 }); +}); diff --git a/src/app/api/v1/quotes/[id]/shots/route.ts b/src/app/api/v1/quotes/[id]/shots/route.ts new file mode 100644 index 0000000..418f782 --- /dev/null +++ b/src/app/api/v1/quotes/[id]/shots/route.ts @@ -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("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("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]); +}); diff --git a/src/app/api/v1/quotes/route.ts b/src/app/api/v1/quotes/route.ts new file mode 100644 index 0000000..cb3bedb --- /dev/null +++ b/src/app/api/v1/quotes/route.ts @@ -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("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("write", async (req: NextRequest) => { + const input = apiQuoteCreateSchema.parse(await readJsonBody(req)); + const quote = await createQuoteFromApi(input); + + revalidatePath("/jobs"); + return apiCreated(quote); +}); diff --git a/src/app/api/v1/rate-cards/route.ts b/src/app/api/v1/rate-cards/route.ts new file mode 100644 index 0000000..eb9958b --- /dev/null +++ b/src/app/api/v1/rate-cards/route.ts @@ -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("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 }); +}); diff --git a/src/app/api/v1/route.ts b/src/app/api/v1/route.ts new file mode 100644 index 0000000..79ce907 --- /dev/null +++ b/src/app/api/v1/route.ts @@ -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("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)", + }, + }, + }) +); diff --git a/src/app/api/v1/shots/[id]/line-items/route.ts b/src/app/api/v1/shots/[id]/line-items/route.ts new file mode 100644 index 0000000..22c71f4 --- /dev/null +++ b/src/app/api/v1/shots/[id]/line-items/route.ts @@ -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("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("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]); +}); diff --git a/src/app/api/v1/shots/[id]/route.ts b/src/app/api/v1/shots/[id]/route.ts new file mode 100644 index 0000000..b4cd6a0 --- /dev/null +++ b/src/app/api/v1/shots/[id]/route.ts @@ -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("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("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; + + 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("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 }); +}); diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 36c6abb..5636c7a 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -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 (
@@ -23,6 +33,12 @@ export default async function SettingsPage() {
+ + {isAdmin && ( +
+ +
+ )}
); } diff --git a/src/components/settings/api-keys-manager.tsx b/src/components/settings/api-keys-manager.tsx new file mode 100644 index 0000000..7639b4b --- /dev/null +++ b/src/components/settings/api-keys-manager.tsx @@ -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 ( +
+
+ +

+ 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. +

+
+ +
+ + {apiKey} + + +
+ +
+ +
+
+ ); +} + +function CreateKeyForm({ + onCreated, + onCancel, +}: { + onCreated: (key: string, row: ApiKeyRow) => void; + onCancel: () => void; +}) { + const { + register, + handleSubmit, + control, + setValue, + formState: { errors, isSubmitting }, + } = useForm({ 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 ( +
+
+ + +

+ Name it after whatever will use it, so you know what breaks if you revoke it. +

+ {errors.name &&

{errors.name.message}

} +
+ +
+
+

Allow writes

+

+ {canWrite + ? "Can create, update and delete quotes, shots, assets and tasks." + : "Read-only: can list and fetch, but not change anything."} +

+
+ setValue("canWrite", v)} /> +
+ +
+ + +
+
+ ); +} + +export function ApiKeysManager({ initialKeys }: { initialKeys: ApiKeyRow[] }) { + const [keys, setKeys] = useState(initialKeys); + const [dialogOpen, setDialogOpen] = useState(false); + const [newKey, setNewKey] = useState(null); + const [revokeTarget, setRevokeTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(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 ( + <> +
+
+

API Keys

+

+ Keys authenticate requests to the xBid API at{" "} + /api/v1. +

+
+ +
+ + {keys.length === 0 ? ( +
+ +

No API keys yet.

+

Create one to let another system file quotes into xBid.

+
+ ) : ( +
+ + + + + + + + + + + + {keys.map((key) => { + const revoked = Boolean(key.revokedAt); + const expired = Boolean(key.expiresAt && new Date(key.expiresAt) <= new Date()); + return ( + + + + + + + + ); + })} + +
NameKeyScopesLast usedActions
+ {key.name} + {revoked && ( + + Revoked + + )} + {!revoked && expired && ( + + Expired + + )} +

+ Created {relative(key.createdAt)} +

+
+ {key.prefix}… + +
+ {key.scopes.map((scope) => ( + + {scope} + + ))} +
+
+ {relative(key.lastUsedAt) ?? "Never"} + + {revoked ? ( + + ) : ( + + )} +
+
+ )} + + (open ? setDialogOpen(true) : closeDialog())}> + + + {newKey ? "API key created" : "New API key"} + + {newKey ? ( + + ) : ( + + )} + + + + !open && setRevokeTarget(null)}> + + + Revoke this key? + + Any integration using {revokeTarget?.name} will start getting 401 + responses immediately. This cannot be undone — you would need to issue a new key. + + + + Cancel + + Revoke key + + + + + + !open && setDeleteTarget(null)}> + + + Delete this key record? + + This removes the record for {deleteTarget?.name} from the list. The + key is already revoked and cannot be used either way. + + + + Cancel + + Delete record + + + + + + ); +} diff --git a/src/lib/api-auth.ts b/src/lib/api-auth.ts new file mode 100644 index 0000000..30f4416 --- /dev/null +++ b/src/lib/api-auth.ts @@ -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 `, 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 { + const presented = extractKey(req); + + if (!presented) { + return { + ok: false, + response: apiError( + "unauthorized", + "Missing API key. Send `Authorization: Bearer `.", + 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 } }; +} diff --git a/src/lib/api-keys.ts b/src/lib/api-keys.ts new file mode 100644 index 0000000..376cf08 --- /dev/null +++ b/src/lib/api-keys.ts @@ -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), + }; +} diff --git a/src/lib/api-quotes.ts b/src/lib/api-quotes.ts new file mode 100644 index 0000000..55b2c23 --- /dev/null +++ b/src/lib/api-quotes.ts @@ -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 { + 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 +): Promise { + 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 { + 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; + byName: Map; +}; + +/** + * 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 { + 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 +) { + 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, + }); + }); +} diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts new file mode 100644 index 0000000..9153818 --- /dev/null +++ b/src/lib/api-response.ts @@ -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 = { + 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(value: T): unknown { + return JSON.parse(JSON.stringify(value ?? null)); +} + +export function apiOk(data: unknown, meta?: Record, 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 +): 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 { + const out: Record = {}; + 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 { + 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."); + } +} diff --git a/src/lib/api-route.ts b/src/lib/api-route.ts new file mode 100644 index 0000000..eb3942e --- /dev/null +++ b/src/lib/api-route.ts @@ -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

= { params: Promise

}; +export type IdContext = ApiContext<{ id: string }>; + +type Handler = ( + req: NextRequest, + ctx: Ctx, + key: AuthenticatedKey +) => Promise; + +/** + * 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(scope: ApiScope, handler: Handler) { + return async (req: NextRequest, ctx: Ctx): Promise => { + 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( + schema: S, + req: NextRequest +): Promise<{ items: z.infer[]; 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(schema: S, req: NextRequest): z.infer { + return schema.parse(Object.fromEntries(req.nextUrl.searchParams)); +} diff --git a/src/lib/api-validations.ts b/src/lib/api-validations.ts new file mode 100644 index 0000000..bc91dad --- /dev/null +++ b/src/lib/api-validations.ts @@ -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; + +/** + * `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; + +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; + +// ─── 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; + +export const apiAssetUpdateSchema = assetFields.partial().extend({ + sortOrder: z.coerce.number().int().min(0).optional(), +}); +export type ApiAssetUpdateInput = z.infer; + +// ─── 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; + +export const apiQuoteUpdateSchema = quoteFields.partial().extend({ + clientId: optionalText, +}); +export type ApiQuoteUpdateInput = z.infer; + +// ─── 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; diff --git a/src/lib/shot-codes.ts b/src/lib/shot-codes.ts new file mode 100644 index 0000000..c78e15c --- /dev/null +++ b/src/lib/shot-codes.ts @@ -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; + + 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; + } +} diff --git a/src/proxy.ts b/src/proxy.ts index 8f1b757..6b2bd71 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -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,