Files
XBid/prisma/schema.prisma
T
twotalesanimation 8a93a18bb9 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>
2026-11-11 09:10:45 +02:00

648 lines
21 KiB
Plaintext

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
// ─── Auth / Users ────────────────────────────────────────────────────────────
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
password String?
role UserRole @default(ARTIST)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
jobs Job[] @relation("JobCreator")
comments Comment[]
@@map("users")
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@map("accounts")
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@map("verification_tokens")
}
enum UserRole {
ADMIN
PRODUCER
SUPERVISOR
ARTIST
}
// ─── Clients ─────────────────────────────────────────────────────────────────
model Client {
id String @id @default(cuid())
companyName String
contactPerson String?
email String?
phone String?
vatNumber String?
companyRegNumber String?
billingAddress String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
jobs Job[]
invoices Invoice[]
@@map("clients")
}
// ─── Jobs / Quotes ────────────────────────────────────────────────────────────
model Job {
id String @id @default(cuid())
projectTitle String
projectCode String @unique
showId String @default("")
jobType JobType @default(STANDARD)
clientId String
createdById String?
issueDate DateTime @default(now())
expiryDate DateTime?
currency String @default("ZAR")
status JobStatus @default(DRAFT)
description String?
internalNotes String?
markupPercent Decimal @default(0) @db.Decimal(10, 4)
taxPercent Decimal @default(0) @db.Decimal(10, 4)
discountPercent Decimal @default(0) @db.Decimal(10, 4)
productionFeePercent Decimal @default(0) @db.Decimal(10, 4)
pullManagementFeePercent Decimal @default(0) @db.Decimal(10, 4)
customHeaderHtml String?
customFooterHtml String?
termsAndConditions String?
startDate DateTime?
estimatedCompletionDate DateTime?
version Int @default(1)
parentJobId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
client Client @relation(fields: [clientId], references: [id])
createdBy User? @relation("JobCreator", fields: [createdById], references: [id])
parentJob Job? @relation("JobVersions", fields: [parentJobId], references: [id], onDelete: SetNull)
versions Job[] @relation("JobVersions")
shots Shot[]
assets Asset[]
supervisions Supervision[]
previses Previs[]
comments Comment[]
history JobHistory[]
paymentSchedule PaymentSchedule?
invoices Invoice[]
@@map("jobs")
}
enum JobStatus {
DRAFT
SENT
APPROVED
REJECTED
ARCHIVED
}
enum JobType {
STANDARD
EPISODIC
}
model JobHistory {
id String @id @default(cuid())
jobId String
field String
oldValue String?
newValue String?
changedAt DateTime @default(now())
changedBy String?
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
@@map("job_history")
}
model Comment {
id String @id @default(cuid())
jobId String
userId String?
body String
createdAt DateTime @default(now())
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id])
@@map("comments")
}
// ─── Rate Cards ───────────────────────────────────────────────────────────────
model RateCard {
id String @id @default(cuid())
name String
department String?
defaultDailyRate Decimal @default(0) @db.Decimal(10, 4)
hoursPerDay Decimal @default(8) @db.Decimal(5, 2)
currency String @default("ZAR")
notes String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lineItems LineItem[]
@@map("rate_cards")
}
// ─── Shot Templates ───────────────────────────────────────────────────────────
model ShotTemplate {
id String @id @default(cuid())
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lineItemTemplates ShotLineItemTemplate[]
@@map("shot_templates")
}
model ShotLineItemTemplate {
id String @id @default(cuid())
templateId String
taskName String
artistRole String?
defaultHours Decimal @default(0) @db.Decimal(10, 4)
rateCardId String?
notes String?
sortOrder Int @default(0)
template ShotTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
@@map("shot_line_item_templates")
}
// ─── Asset Templates ──────────────────────────────────────────────────────────
model AssetTemplate {
id String @id @default(cuid())
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lineItemTemplates AssetLineItemTemplate[]
@@map("asset_templates")
}
model AssetLineItemTemplate {
id String @id @default(cuid())
templateId String
taskName String
artistRole String?
defaultHours Decimal @default(0) @db.Decimal(10, 4)
rateCardId String?
notes String?
sortOrder Int @default(0)
template AssetTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
@@map("asset_line_item_templates")
}
// ─── Shots ────────────────────────────────────────────────────────────────────
model Shot {
id String @id @default(cuid())
jobId String
shotCode String
shotNumber Int @default(0)
scene String @default("")
episode String?
shotGroup String?
description String?
durationSeconds Int?
complexityMultiplier Decimal @default(1) @db.Decimal(5, 4)
notes String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
lineItems LineItem[]
@@map("shots")
}
// ─── Assets ───────────────────────────────────────────────────────────────────
model Asset {
id String @id @default(cuid())
jobId String
assetName String
assetType String?
description String?
complexityMultiplier Decimal @default(1) @db.Decimal(5, 4)
notes String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
lineItems LineItem[]
@@map("assets")
}
// ─── Supervision ─────────────────────────────────────────────────────────────
model Supervision {
id String @id @default(cuid())
jobId String
supervisionTitle String
shootName String?
location String?
shootDates String?
numShootDays Int @default(0)
travelDays Int @default(0)
prepDays Int @default(0)
wrapDays Int @default(0)
description String?
complexityMultiplier Decimal @default(1) @db.Decimal(5, 4)
notes String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
lineItems LineItem[]
@@map("supervisions")
}
model SupervisionTemplate {
id String @id @default(cuid())
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lineItemTemplates SupervisionLineItemTemplate[]
@@map("supervision_templates")
}
model SupervisionLineItemTemplate {
id String @id @default(cuid())
templateId String
taskName String
artistRole String?
defaultHours Decimal @default(0) @db.Decimal(10, 4)
rateCardId String?
notes String?
sortOrder Int @default(0)
template SupervisionTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
@@map("supervision_line_item_templates")
}
// ─── Previs ───────────────────────────────────────────────────────────────────
model Previs {
id String @id @default(cuid())
jobId String
sequenceName String
previsCode String?
sequenceType String @default("Previs")
description String?
duration String?
frameRange String?
revisionCount Int @default(0)
deliveryStage String @default("Blocking")
complexityMultiplier Decimal @default(1) @db.Decimal(5, 4)
notes String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
lineItems LineItem[]
@@map("previses")
}
model PrevisTemplate {
id String @id @default(cuid())
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lineItemTemplates PrevisLineItemTemplate[]
@@map("previs_templates")
}
model PrevisLineItemTemplate {
id String @id @default(cuid())
templateId String
taskName String
artistRole String?
defaultHours Decimal @default(0) @db.Decimal(10, 4)
rateCardId String?
notes String?
sortOrder Int @default(0)
template PrevisTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
@@map("previs_line_item_templates")
}
// ─── Line Items ───────────────────────────────────────────────────────────────
model LineItem {
id String @id @default(cuid())
shotId String?
assetId String?
supervisionId String?
previsId String?
rateCardId String?
taskName String
artistRole String?
// Inputs
quantityHours Decimal @default(0) @db.Decimal(10, 4)
dailyRate Decimal @default(0) @db.Decimal(10, 4)
hoursPerDay Decimal @default(8) @db.Decimal(5, 2)
numArtists Decimal @default(1) @db.Decimal(5, 2)
numDays Decimal @default(0) @db.Decimal(10, 4)
// Overrides
manualHourlyRate Decimal? @db.Decimal(10, 4)
manualSubtotal Decimal? @db.Decimal(14, 4)
// Adjustments
markupPercent Decimal @default(0) @db.Decimal(10, 4)
discountPercent Decimal @default(0) @db.Decimal(10, 4)
taxPercent Decimal @default(0) @db.Decimal(10, 4)
// Stored computed totals (recalculated server-side before PDF)
hourlyRate Decimal @default(0) @db.Decimal(10, 4)
subtotal Decimal @default(0) @db.Decimal(14, 4)
markupAmount Decimal @default(0) @db.Decimal(14, 4)
discountAmount Decimal @default(0) @db.Decimal(14, 4)
taxAmount Decimal @default(0) @db.Decimal(14, 4)
total Decimal @default(0) @db.Decimal(14, 4)
notes String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
shot Shot? @relation(fields: [shotId], references: [id], onDelete: Cascade)
asset Asset? @relation(fields: [assetId], references: [id], onDelete: Cascade)
supervision Supervision? @relation(fields: [supervisionId], references: [id], onDelete: Cascade)
previs Previs? @relation(fields: [previsId], references: [id], onDelete: Cascade)
rateCard RateCard? @relation(fields: [rateCardId], references: [id])
@@map("line_items")
}
// ─── Task Templates ───────────────────────────────────────────────────────────
model TaskTemplate {
id String @id @default(cuid())
name String
taskName String
artistRole String?
rateCardId String?
quantityHours Decimal @default(0) @db.Decimal(10, 4)
dailyRate Decimal @default(0) @db.Decimal(10, 4)
hoursPerDay Decimal @default(8) @db.Decimal(5, 2)
numArtists Decimal @default(1) @db.Decimal(5, 2)
numDays Decimal @default(0) @db.Decimal(10, 4)
manualHourlyRate Decimal? @db.Decimal(10, 4)
manualSubtotal Decimal? @db.Decimal(14, 4)
markupPercent Decimal @default(0) @db.Decimal(10, 4)
discountPercent Decimal @default(0) @db.Decimal(10, 4)
taxPercent Decimal @default(0) @db.Decimal(10, 4)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("task_templates")
}
// ─── Studio Settings ──────────────────────────────────────────────────────────
model StudioSettings {
id String @id @default("default")
studioName String @default("My VFX Studio")
studioEmail String?
studioPhone String?
studioAddress String?
logoUrl String?
vatNumber String?
companyRegNumber String?
invoiceHeader String?
invoiceFooter String?
defaultTermsAndConditions String?
defaultCurrency String @default("ZAR")
defaultMarkupPercent Decimal @default(0) @db.Decimal(10, 4)
defaultDiscountPercent Decimal @default(0) @db.Decimal(10, 4)
defaultTaxPercent Decimal @default(0) @db.Decimal(10, 4)
updatedAt DateTime @updatedAt
@@map("studio_settings")
}
// ─── Payment Schedule ─────────────────────────────────────────────────────────
enum ScheduleTemplate {
STANDARD_FILM
SMALL_COMMERCIAL
EPISODIC
CUSTOM
}
enum MilestoneStatus {
DRAFT
PENDING
INVOICED
PARTIALLY_PAID
PAID
OVERDUE
CANCELLED
}
model PaymentSchedule {
id String @id @default(cuid())
jobId String @unique
template ScheduleTemplate @default(CUSTOM)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
milestones PaymentMilestone[]
@@map("payment_schedules")
}
model PaymentMilestone {
id String @id @default(cuid())
scheduleId String
title String
description String?
dueDate DateTime?
percentOfTotal Decimal @default(0) @db.Decimal(10, 4)
fixedAmount Decimal? @db.Decimal(14, 4)
status MilestoneStatus @default(PENDING)
paymentReceivedDate DateTime?
notes String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
schedule PaymentSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
invoice Invoice?
@@map("payment_milestones")
}
// ─── Invoices ─────────────────────────────────────────────────────────────────
enum InvoiceStatus {
DRAFT
SENT
VIEWED
PARTIALLY_PAID
PAID
OVERDUE
CANCELLED
}
enum PaymentMethod {
EFT
CASH
CREDIT_CARD
BANK_TRANSFER
CRYPTO
OTHER
}
model Invoice {
id String @id @default(cuid())
invoiceNumber String @unique
clientId String
jobId String
milestoneId String? @unique
status InvoiceStatus @default(DRAFT)
issueDate DateTime @default(now())
dueDate DateTime?
paymentTerms String?
currency String @default("ZAR")
subtotal Decimal @default(0) @db.Decimal(14, 4)
taxPercent Decimal @default(0) @db.Decimal(10, 4)
taxAmount Decimal @default(0) @db.Decimal(14, 4)
totalAmount Decimal @default(0) @db.Decimal(14, 4)
amountPaid Decimal @default(0) @db.Decimal(14, 4)
amountDue Decimal @default(0) @db.Decimal(14, 4)
notes String?
internalNotes String?
bankingDetails String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
client Client @relation(fields: [clientId], references: [id])
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
milestone PaymentMilestone? @relation(fields: [milestoneId], references: [id])
payments InvoicePayment[]
@@map("invoices")
}
model InvoicePayment {
id String @id @default(cuid())
invoiceId String
amount Decimal @db.Decimal(14, 4)
paymentDate DateTime
paymentMethod PaymentMethod @default(EFT)
reference String?
notes String?
createdAt DateTime @default(now())
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
@@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")
}