Files
craft2prints/prisma/schema.prisma
T
AndymickandClaude Sonnet 5 396347b982 Move uploaded images to disk storage, fix Next 16 async params/searchParams
Homepage was rendering 71MB of HTML because every uploaded image (gallery,
products, proofs, custom requests, design previews, invoices, receipts) was
stored as base64 in Postgres and double-embedded via RSC hydration payloads.
Adds src/lib/storage.ts (sharp-based compression, disk storage under
public/uploads/) and a new /api/design-uploads endpoint for images added
inside the design tool, migrates existing rows, and drops the now-unused
base64 columns and the dead ProductImage table.

Also fixes a systemic bug from the Next.js 16 upgrade where dynamic pages
across the app still destructured params/searchParams synchronously instead
of awaiting them as Promises, which was silently breaking filters/params and
crashing /products/[slug] outright.

Updates README.md and SETUP_AND_BUILD.md to reflect Postgres + Next.js 16 and
document the new image storage architecture and backup requirements.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 20:56:40 +01:00

574 lines
27 KiB
Plaintext

generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Categories are their own table so you can add/rename/remove them from the admin
// panel. Product.category stays a plain string (matching Category.slug) rather
// than a foreign key, so nothing breaks if a category is renamed after the fact.
// Categories can be hierarchical: parentId links to a parent category.
model Category {
id String @id @default(cuid())
name String // display label, e.g. "Phone Cases"
slug String @unique // stored key, e.g. "PHONE_CASE" — matches Product.category
parentId String? // parent category ID for hierarchical organization (e.g., "Apparel - Adults" has parent "Apparel")
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
children Category[] @relation("CategoryHierarchy")
showOnPersonalised Boolean @default(true) // appears as a filter on /made-to-order
showOnProducts Boolean @default(true) // appears as a filter on /products
createdAt DateTime @default(now())
}
model Product {
id String @id @default(cuid())
slug String @unique
name String
category String
description String
basePrice Int // cents — used for ready-made items on /products
plainPrice Int? // cents — used for plain/blank items
personalisedPrice Int? // cents — used for made-to-order items on /made-to-order; falls back to basePrice if not set
colors String // JSON array of hex strings, e.g. ["#17181C","#FBF9F5"]
colorPhotos String? // JSON object mapping hex -> photo data URL, e.g. {"#17181C":"data:..."}.
colorPhotosBack String? // same idea, for the back view of each color
// Lets a product show the real photo for whichever color is selected, instead of
// tinting one photo. Falls back to imageUrl/photoRecolorable/mockup for any color
// that doesn't have its own photo here.
sizes String? // JSON array of size labels, e.g. ["S","M","L","XL"] — leave null for products with no sizing (mugs, phone cases)
mockup String // which SVG mockup component to render, e.g. "tshirt" | "hoodie" | "mug" | "phonecase"
printArea String // JSON: {"xPct":..,"yPct":..,"wPct":..,"hPct":..}
printAreaBack String? // same shape, for the back print area — only used when imageUrlBack is set
printAreaWidthMm Int @default(200) // width of the printable area in millimeters
printAreaHeightMm Int @default(280) // height of the printable area in millimeters
referenceWidthCm Int? // width of garment/object for ruler reference (cm)
referenceHeightCm Int? // height of garment/object for ruler reference (cm)
weightGrams Int? // weight in grams (used for Royal Mail postage calculation via API)
imageUrl String? // optional real product photo — shown instead of the illustration when set
imageUrlBack String? // optional back-view photo, toggled alongside the front photo
photoRecolorable Boolean @default(false) // if true, the photo is treated as a grayscale
// "shading map" and multiplied by the selected color swatch live in the browser,
// instead of being shown as a fixed image. Only looks right for black/white/gray
// source photos — recoloring an already-colored photo produces muddy results.
showOnPersonalised Boolean @default(true) // list on /made-to-order
showAsPlain Boolean @default(false) // list on /products/plain — blank/unprinted items
showAsReadyMade Boolean @default(false) // list on /products/ready-made — pre-finished items
createdAt DateTime @default(now())
salePrice Int? // cents — set alongside a date window below to put this product on sale
saleStartsAt DateTime? // leave null to start the sale immediately
saleEndsAt DateTime? // leave null for an open-ended sale
orderItems OrderItem[]
proofs DesignProof[]
customRequests CustomRequest[]
designApprovals DesignApproval[]
reviews Review[]
promotions Promotion[] // only relevant to promotions scoped to specific items, not "all items"
collections Collection[] // occasion/recipient tags, e.g. "Christmas", "For Teachers"
manualSaleItems ManualSaleItem[]
stockLevels StockLevel[]
purchaseItems PurchaseItem[]
}
// Quantity on hand for one product/colour/size combination. Purchases increase
// it, paid website orders and manual sales decrease it. Info-only: counts can
// go negative (shown highlighted in admin as "your records are out of sync")
// and never block anything on the shop side.
model StockLevel {
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
color String
size String @default("") // '' = product has no sizes — a real empty value, not NULL, so the unique index below actually dedupes (SQLite treats NULLs as distinct)
quantity Int @default(0)
updatedAt DateTime @updatedAt
@@unique([productId, color, size])
}
// Stock coming in: one supplier invoice, entered by hand with the invoice
// file (PDF or photo) attached for record-keeping. Product lines add to
// StockLevel; material lines (ink, vinyl, packaging) are just logged — they
// have no running count, and will feed the expenses list in a later stage.
// Every Purchase auto-creates an Expense on creation.
model Purchase {
id String @id @default(cuid())
supplierName String
purchasedAt DateTime @default(now()) // invoice date, editable — not entry time
invoiceUrl String? // disk path under /uploads/invoices/...
invoiceFileName String?
total Int // pence — sum of line totals, recomputed server-side
notes String?
createdAt DateTime @default(now())
items PurchaseItem[]
expense Expense? // auto-created on Purchase creation; cascade delete if Purchase is deleted
}
model PurchaseItem {
id String @id @default(cuid())
purchaseId String
purchase Purchase @relation(fields: [purchaseId], references: [id], onDelete: Cascade)
productId String? // null = loose material line (no stock tracking)
product Product? @relation(fields: [productId], references: [id])
description String // product name snapshot, or the material description
color String?
size String?
quantity Int
unitCost Int // pence
}
// Expenses: everything that goes out. Auto-created from purchases (category =
// "Stock purchases"), plus manual entries for postage, utilities, equipment, etc.
// Receipt can be the invoice file or a standalone attachment. Pence storage like
// everything else.
model Expense {
id String @id @default(cuid())
date DateTime @default(now())
description String
category String @default("Stock purchases") // "Stock purchases", "Postage", others as user enters them
amount Int // pence
receiptUrl String? // disk path under /uploads/receipts/...
receiptFileName String?
notes String?
purchaseId String? @unique // null = standalone expense; set = auto-created from a Purchase (one-to-one)
purchase Purchase? @relation(fields: [purchaseId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
}
// A design you (the shop owner) upload for one specific customer to review and buy —
// separate from the self-serve design tool. You create one of these per custom job,
// send the customer the link, and they approve it straight into their cart.
model DesignProof {
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id])
customerName String?
customerEmail String
color String
quantity Int @default(1)
unitPrice Int // cents — defaults to the product's base price if not overridden
imageUrl String // disk path under /uploads/proofs/...
note String? // optional message shown alongside the design
status String @default("PENDING") // PENDING | APPROVED
createdAt DateTime @default(now())
messages Message[]
}
// One chat thread per design proof — simple polling-based chat, no websockets needed.
model Message {
id String @id @default(cuid())
proofId String
proof DesignProof @relation(fields: [proofId], references: [id], onDelete: Cascade)
sender String // "ADMIN" | "CUSTOMER"
body String
isRead Boolean @default(false)
createdAt DateTime @default(now())
}
// Customer-submitted designs pending admin approval before checkout
model DesignApproval {
id String @id @default(cuid())
customerId String? // null for guest customers
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
customerEmail String
customerName String?
productId String
product Product @relation(fields: [productId], references: [id])
designJson String // serialized {front:[...], back:[...]} design elements
designPreviewUrl String // base64 PNG data URL — front design, transparent, print-ready
designPreviewUrlBack String? // same, for the back design if there is one
placementPreviewUrl String? // front design on product photo
placementPreviewUrlBack String? // back design on product photo
designWidthCm Float? // width of design in centimeters (legacy, for backward compatibility)
designHeightCm Float? // height of design in centimeters (legacy, for backward compatibility)
designImageDimensions String? // JSON array of {imageId, widthCm, heightCm} for each uploaded image
color String
size String?
status String @default("PENDING") // PENDING | IN_REVIEW | APPROVED | REJECTED
expectedDeliveryDate DateTime? // admin sets this when approving the design
messages DesignApprovalMessage[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Chat messages for design approval discussions
model DesignApprovalMessage {
id String @id @default(cuid())
designId String
design DesignApproval @relation(fields: [designId], references: [id], onDelete: Cascade)
sender String // "ADMIN" | "CUSTOMER"
body String
messageType String @default("MESSAGE") // MESSAGE | CHANGE_REQUEST
createdAt DateTime @default(now())
}
model Order {
id String @id @default(cuid())
stripeSessionId String? @unique
email String?
shippingAddress String? // JSON — collected by Stripe Checkout, saved once payment completes
shippingCountry String? // ISO 2-letter country code (e.g., 'GB', 'US') for Royal Mail shipping
status String @default("PENDING") // PENDING | PAID | PRINTING | PRINTED | SHIPPED | DELIVERED | FAILED
archived Boolean @default(false)
total Int // cents (includes shipping cost)
shippingCost Int @default(0) // cents — Royal Mail postage cost
// Shipping & tracking info
shippingService String? // e.g. "Royal Mail 24®", "International Signed" — selected by customer at checkout
carrier String? @default("Royal Mail") // e.g. "Royal Mail", "DPD", "Courier"
trackingNumber String? // tracking number from carrier
estimatedDeliveryDate DateTime? // estimated delivery date
collectedByCustomer Boolean @default(false) // true if order was collected by customer (no shipping)
isCollectionPickup Boolean @default(false) // true if customer selected collection at checkout
readyForCollection Boolean @default(false) // true when admin marks order as ready for pickup
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customerId String? // set when the buyer was logged in at checkout — null for guest orders
customer Customer? @relation(fields: [customerId], references: [id])
guestName String? // full name for guest orders
guestPhone String? // contact phone for guest orders
phoneNumber String? // phone number for WhatsApp notifications (collection orders)
items OrderItem[]
}
// A customer's own account — separate from the ADMIN_USER/ADMIN_PASSWORD admin login.
// Optional: checkout still works as a guest with no Customer record at all.
model Customer {
id String @id @default(cuid())
email String @unique
passwordHash String
name String?
phone String? // delivery phone number
addressLine1 String? // street address
addressLine2 String? // apartment, suite, etc.
city String?
postalCode String?
country String? // ISO 2-letter country code, e.g., "GB"
createdAt DateTime @default(now())
orders Order[]
passwordResetToken PasswordResetToken?
customRequests CustomRequest[]
manualSales ManualSale[]
designApprovals DesignApproval[]
reviews Review[]
}
// A sale that didn't go through the website checkout — cash at a fair, a bank
// transfer arranged over Facebook, etc. Recorded by the admin from
// /admin/accounts so every sale (web + offline) lives in one ledger.
model ManualSale {
id String @id @default(cuid())
soldAt DateTime @default(now()) // when the sale actually happened (editable, not just entry time)
buyerName String? // free text — a cash buyer doesn't need an account
buyerEmail String?
customerId String? // auto-linked when buyerEmail matches a Customer account
customer Customer? @relation(fields: [customerId], references: [id])
paymentMethod String // CASH | BANK_TRANSFER
total Int // pence — sum of line totals, computed server-side on save
notes String?
createdAt DateTime @default(now())
items ManualSaleItem[]
}
model ManualSaleItem {
id String @id @default(cuid())
saleId String
sale ManualSale @relation(fields: [saleId], references: [id], onDelete: Cascade)
productId String? // null for free-text one-offs (e.g. "custom commission")
product Product? @relation(fields: [productId], references: [id])
description String // product name snapshot, or the free text itself
color String?
size String?
quantity Int
unitPrice Int // pence
}
// One active reset token per customer at a time — requesting a new one replaces it.
model PasswordResetToken {
id String @id @default(cuid())
customerId String @unique
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
}
// A customer's own photo, submitted for the shop to design something with —
// separate from the self-serve design tool and from DesignProof (which is the
// finished result you send back once you've worked on it). No FK link between
// the two: turning a request into a proof is a one-time action, not a tracked relation.
model CustomRequest {
id String @id @default(cuid())
customerId String?
customer Customer? @relation(fields: [customerId], references: [id])
productId String
product Product @relation(fields: [productId], references: [id])
customerName String
customerEmail String
customerPhone String?
imageUrl String // disk path under /uploads/custom-requests/...
note String?
status String @default("NEW") // NEW | DONE
createdAt DateTime @default(now())
}
// A promotion: shown as a banner on every page while its date window is active,
// AND actually discounts prices — either every product ("ALL") or a hand-picked
// set ("SPECIFIC", via the products relation below). Kept as a list rather than
// a single settings row so past promotions aren't destroyed when you create a
// new one. Price discounts stack with a product's own manual sale price by
// taking whichever is lower — see getEffectivePrice in src/lib/pricing.ts.
model Promotion {
id String @id @default(cuid())
message String
discountPercent Int // 1-100
scope String @default("ALL") // ALL | SPECIFIC
startsAt DateTime? // leave null to start immediately
endsAt DateTime? // leave null for an open-ended promotion
createdAt DateTime @default(now())
products Product[] // only consulted when scope = SPECIFIC
}
// Occasion ("Christmas", "Birthdays") and recipient ("Teachers", "School leavers") tags
// for the two nav dropdowns of the same name. One model with a group discriminator
// (rather than two models) so there's a single set of admin pages — split into
// sections by group instead. A product can carry any number of both, hence the
// many-to-many (unlike Product.category, which is a single string).
model Collection {
id String @id @default(cuid())
name String
slug String @unique
group String // "OCCASION" | "RECIPIENT"
createdAt DateTime @default(now())
products Product[]
}
// Unified mailing list — fed by three separate sources (customer registration,
// the footer newsletter signup, and guest checkout emails) so a single admin
// broadcast can reach all of them without querying three different shapes.
// `subscribed` is the admin's opt-out switch and is deliberately never
// overwritten by a later upsert from any source, so it always wins.
model MailingListEntry {
id String @id @default(cuid())
email String @unique
name String?
source String // "CUSTOMER" | "NEWSLETTER" | "GUEST" — where this address first came from
subscribed Boolean @default(true)
createdAt DateTime @default(now())
}
// Backs a small DB-based rate limiter (src/lib/rateLimit.ts) — chosen over an
// in-memory counter because that would silently reset on every serverless
// cold start and not share state across instances. Rows older than 24h are
// opportunistically pruned by the limiter itself, no separate cron needed.
model RateLimitAttempt {
id String @id @default(cuid())
key String // e.g. "login:customer:<ip>", "checkout:<ip>"
createdAt DateTime @default(now())
@@index([key, createdAt])
}
// Site-wide settings — a single row (fixed id "singleton"). Currently just the
// maintenance-mode toggle: when on, customers see a "we're updating" page while
// logged-in admins still see the real site. Read on every page render (cheap
// single-row lookup), written only from the admin maintenance page.
model SiteSetting {
id String @id @default("singleton")
maintenanceMode Boolean @default(false)
maintenanceMessage String?
}
// Footer email signup — just captures addresses for you to use once you pick an
// email tool (Mailchimp, Klaviyo, etc.). Not wired up to send anything itself.
model NewsletterSubscriber {
id String @id @default(cuid())
email String @unique
createdAt DateTime @default(now())
}
// Photo gallery of completed work, shown on the public /gallery page. Photos are
// uploaded manually in the admin (base64 data URLs, same as products/proofs).
// Categories here are their own thing, separate from product Category — a photo's
// category is optional. (Future: an official Facebook Graph API import could add a
// nullable source/attribution column here — not built yet.)
model GalleryCategory {
id String @id @default(cuid())
name String
slug String @unique
createdAt DateTime @default(now())
photos GalleryPhoto[]
}
model GalleryPhoto {
id String @id @default(cuid())
imageUrl String // disk path under /uploads/gallery/...
caption String?
categoryId String? // nullable — a photo can be uncategorized
category GalleryCategory? @relation(fields: [categoryId], references: [id])
createdAt DateTime @default(now())
}
model OrderItem {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
productId String
product Product @relation(fields: [productId], references: [id])
productName String // snapshot at time of order, in case the product changes later
quantity Int
color String
size String?
unitPrice Int // cents
designJson String // serialized {front:[...], back:[...]} design elements
designPreviewUrl String // base64 PNG data URL — front design, transparent, print-ready
designPreviewUrlBack String? // same, for the back design if there is one
placementPreviewUrl String? // front design shown composited on the actual product photo, for reference — where does this go when printing
placementPreviewUrlBack String? // same, for the back
designWidthCm Float? // width of design in centimeters (legacy)
designHeightCm Float? // height of design in centimeters (legacy)
designImageDimensions String? // JSON array of {imageId, widthCm, heightCm} for each uploaded image
}
// Admin user account — stores admin credentials (can override env vars if present)
model AdminUser {
id String @id @default(cuid())
username String @unique
passwordHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Audit log for admin logins — tracks when the admin panel is accessed
model AdminLoginLog {
id String @id @default(cuid())
loginAt DateTime @default(now())
logoutAt DateTime? // null = still logged in (or session expired without explicit logout)
ipAddress String?
userAgent String?
success Boolean @default(true) // false for failed login attempts
}
// Audit log for customer logins — tracks customer account access
model CustomerLoginLog {
id String @id @default(cuid())
customerId String?
email String // captured for failed attempts where customer doesn't exist
loginAt DateTime @default(now())
ipAddress String?
userAgent String?
success Boolean @default(true) // false for failed login attempts
}
// Audit log for financial transactions — tracks all changes to financial data
model FinancialAuditLog {
id String @id @default(cuid())
action String // CREATE, UPDATE, DELETE
entityType String // "ORDER", "MANUAL_SALE", "PURCHASE", "EXPENSE"
entityId String // ID of the order/sale/purchase/expense
changes String // JSON: {field: {old: X, new: Y}, ...}
amount Int? // cents, for quick filtering of high-value changes
changedAt DateTime @default(now())
changedBy String? // "SYSTEM" or IP address if admin
reason String? // why was this changed
}
// Bank statement uploaded for reconciliation
model BankStatement {
id String @id @default(cuid())
filename String // original filename
bankName String? // e.g., "HSBC", "Barclays"
accountNumber String? // last 4 digits for safety
statementMonth DateTime // the month this statement covers
startDate DateTime // first transaction date in statement
endDate DateTime // last transaction date in statement
totalTransactions Int // count of lines in statement
totalAmount Int? // cents, total of all transactions (for verification)
status String @default("PENDING") // PENDING, IN_PROGRESS, RECONCILED, ARCHIVED
uploadedAt DateTime @default(now())
reconciliedAt DateTime? // when reconciliation was completed
lines BankStatementLine[]
reconciliations BankReconciliation[]
}
// Individual transaction line from a bank statement
model BankStatementLine {
id String @id @default(cuid())
statementId String
statement BankStatement @relation(fields: [statementId], references: [id], onDelete: Cascade)
date DateTime // transaction date
description String // e.g., "STRIPE DEPOSIT", "TRANSFER TO SAVINGS"
amount Int // cents, positive for credit, negative for debit
balance Int? // account balance after this transaction (if provided)
reference String? // transaction reference from bank
matched Boolean @default(false) // whether this line has been reconciled
reconciliation BankReconciliation? // the matched transaction, if any
createdAt DateTime @default(now())
}
// Link between system transaction (order/sale/expense) and bank statement line
model BankReconciliation {
id String @id @default(cuid())
statementId String
statement BankStatement @relation(fields: [statementId], references: [id], onDelete: Cascade)
bankLineId String @unique
bankLine BankStatementLine @relation(fields: [bankLineId], references: [id], onDelete: Cascade)
// Which system transaction this was matched to
transactionType String // "ORDER", "MANUAL_SALE", "REFUND", "EXPENSE", "TRANSFER_OUT", "FEE", "OTHER"
transactionId String? // ID of the order/sale/expense if applicable
description String // what the match was (e.g., "Order #12345", "Stripe fee")
systemAmount Int // cents, amount in our system
bankAmount Int // cents, amount on bank statement
amountMatches Boolean // whether system and bank amounts are equal
matchedAt DateTime @default(now())
matchedBy String? // IP/admin who manually matched this (null if auto-matched)
confidence Int @default(100) // 0-100, how confident the match is (100=exact amount+date, <100=fuzzy match)
notes String? // manual notes about the match
}
// Customer reviews for products
model Review {
id String @id @default(cuid())
customerId String? // null for guest customers
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
customerEmail String
customerName String?
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
rating Int // 1-5 stars
title String // short headline
comment String // full review text
approved Boolean @default(false) // must be approved by admin before showing publicly
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Shipping rates configuration — managed from admin panel
// Allows easy updates to Royal Mail pricing without code changes
model ShippingRate {
id String @id @default(cuid())
service String @unique // e.g., "Royal Mail 24®", "International Signed"
basePrice Int // cents — base price for this service
pricePerKg Int @default(0) // cents — additional cost per kg (mainly for international)
estimatedDays Int // estimated delivery days
isInternational Boolean @default(false)
active Boolean @default(true)
updatedAt DateTime @updatedAt
}