Files
craft2prints/prisma/schema.prisma
T
Andymick 440a735b8a Feature: Add order tracking and shipping status updates
- Add carrier, trackingNumber, and estimatedDeliveryDate fields to Order model
- Update order statuses: PENDING, PAID, PRINTING, PRINTED, SHIPPED, DELIVERED, FAILED
- Add admin UI on order detail page to update tracking information
- Display tracking info on customer account page in order history
- Server action to update order tracking and status

Customers can now see their shipping status and tracking details from
their account page. Admins can update order status and tracking info
from the order detail page.
2026-07-18 20:34:19 +01:00

525 lines
24 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.
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
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
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)
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
showOnProducts Boolean @default(false) // list on /products — same product/photo can show on both
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[]
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[]
productImages ProductImage[]
}
// Bulk-uploaded product images with auto-detected colors. Supports any product type
// (tees, hoodies, mugs, etc.). User uploads a folder, colors are extracted, then saved
// as color-swatch references for the product's color palette.
model ProductImage {
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
color String // hex color detected from the image, e.g. "#1EA7E0"
imageDataUrl String // the uploaded image itself as a data URL
createdAt DateTime @default(now())
@@unique([productId, color])
}
// 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
invoiceDataUrl String? // the uploaded invoice itself, as a data URL
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
receiptDataUrl String? // uploaded receipt as data URL
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
imageDataUrl String // the finished design image you uploaded, shown as-is to the customer
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
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
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
status String @default("PENDING") // PENDING | PAID | PRINTING | PRINTED | SHIPPED | DELIVERED | FAILED
archived Boolean @default(false)
total Int // cents
// Shipping & tracking info
carrier String? // e.g. "Royal Mail", "DPD", "Courier"
trackingNumber String? // tracking number from carrier
estimatedDeliveryDate DateTime? // estimated delivery date
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
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?
createdAt DateTime @default(now())
orders Order[]
passwordResetToken PasswordResetToken?
customRequests CustomRequest[]
manualSales ManualSale[]
designApprovals DesignApproval[]
}
// 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?
imageDataUrl String // the photo they want us to work with
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())
imageDataUrl String // base64 data URL
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
}
// 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
}