Add REST API for quotes, shots, assets and tasks

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

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

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

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

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

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

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

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

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