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) 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 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 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 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:", "checkout:" 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 }