Initial commit: Craft2Prints with Stages 1-3
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
// Swap to "postgresql" for production scale — just change provider + DATABASE_URL.
|
||||
provider = "sqlite"
|
||||
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
|
||||
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[]
|
||||
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
|
||||
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())
|
||||
}
|
||||
|
||||
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 | FAILED
|
||||
archived Boolean @default(false)
|
||||
total Int // cents
|
||||
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])
|
||||
|
||||
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[]
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user