// 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" } // Note that some adapters may set a maximum length for the String type by default, please ensure your strings are long // enough when changing adapters. // See https://www.prisma.io/docs/orm/reference/prisma-schema-reference#string for more information datasource db { provider = "postgresql" url = env("DATABASE_URL") } model Session { id String @id shop String state String isOnline Boolean @default(false) scope String? expires DateTime? accessToken String userId BigInt? firstName String? lastName String? email String? accountOwner Boolean @default(false) locale String? collaborator Boolean? @default(false) emailVerified Boolean? @default(false) refreshToken String? refreshTokenExpires DateTime? } // --- Scheduling domain (IMPLEMENTATION_PLAN.md §4) ------------------------- // Every model below is scoped by shopDomain (multi-tenant, per CLAUDE.md). // Built incrementally per phase; see the plan for models not yet added // (CapacityResource, Zone, ProductRule, Rate, SlotHold, Booking, ...). enum Method { SHIPPING LOCAL_DELIVERY PICKUP } model Shop { id String @id @default(cuid()) shopDomain String @unique plan String @default("basic") // basic|shopify|advanced|plus tier String @default("free") // free|starter|growth|pro timezone String @default("UTC") settings Json @default("{}") // widget copy, i18n, feature flags // Checkout enforcement scope for the Cart/Checkout Validation Function // (study §3.5 — enforcement should be scopable, not all-or-nothing). // "all" → every order must carry a valid slot selection // "tagged" → only orders containing a product tagged `enforcementTag` // "off" → the Function never blocks (widget still collects) // The Function can't read this DB, so it's mirrored into the shop // metafield snapshot written by checkout-snapshot.server.ts. enforcementMode String @default("all") // all|tagged|off enforcementTag String? // product tag that triggers enforcement when mode = "tagged" createdAt DateTime @default(now()) } model Location { id String @id @default(cuid()) shopDomain String name String address String lat Float? lng Float? timezone String active Boolean @default(true) // Per-day order cap across every slot/method at this location (study §3.3 // "a maximum number of orders … allowed in a given slot **or day**") — // once a calendar date reaches this many confirmed bookings, all of that // date's slots disappear from the picker. Null = no daily cap. dailyOrderCap Int? // Maps this row to Shopify's own Location resource (gid://shopify/Location/…) // so inventory-based exclusion (Phase 5) can query stock at the right // Shopify location — optional since not every merchant needs it wired up. shopifyLocationId String? slotTemplates SlotTemplate[] overrides SlotOverride[] blackouts BlackoutDate[] bookings Booking[] zones Zone[] createdAt DateTime @default(now()) @@index([shopDomain]) } model SlotTemplate { id String @id @default(cuid()) shopDomain String locationId String location Location @relation(fields: [locationId], references: [id], onDelete: Cascade) method Method weekday Int // 0-6 (0 = Sunday), location-local startMin Int // minutes from midnight, local endMin Int capacity Int // default order capacity if no resources cutoffMin Int? // cutoff before slot (minutes) leadTimeMin Int @default(0) // Shipping-only "date range" parity item (PRODUCT_STRATEGY.md §2): SHIPPING // has no meaningful time-of-day slot, so instead of a start/end time the // shopper is shown an estimated ARRIVAL date range — ship date + this // transit-time spread. Null for PICKUP/LOCAL_DELIVERY, where the slot's own // startMin/endMin is already the precise, meaningful window. transitMinDays Int? transitMaxDays Int? createdAt DateTime @default(now()) @@index([shopDomain, locationId, method, weekday]) } model SlotOverride { id String @id @default(cuid()) shopDomain String locationId String location Location @relation(fields: [locationId], references: [id], onDelete: Cascade) date DateTime // date-only, location-local method Method closed Boolean @default(false) startMin Int? endMin Int? capacity Int? createdAt DateTime @default(now()) @@index([shopDomain, locationId, date]) } model BlackoutDate { id String @id @default(cuid()) shopDomain String locationId String? // null = all locations location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade) method Method? // null = all methods date DateTime reason String? source String @default("manual") // manual|holiday-import createdAt DateTime @default(now()) @@index([shopDomain, date]) } model Booking { id String @id @default(cuid()) shopDomain String orderId String @unique // Shopify order GID — also our idempotency key for webhook retries orderName String? // e.g. "#1001", for display only locationId String location Location @relation(fields: [locationId], references: [id]) // Which delivery Zone the order was routed through, if any (LOCAL_DELIVERY // orders matched by zones.server.ts). Nullable: PICKUP/SHIPPING orders and // any LOCAL_DELIVERY order placed before zones existed have none. Not a // relation (no onDelete behavior wanted if a zone is later removed) — // deliberately just an id for the delivery-density threshold check in // zones.server.ts to count "how many orders have already routed here." zoneId String? method Method slotStart DateTime slotEnd DateTime status String @default("confirmed") // confirmed|cancelled|fulfilled|no_show customerEmail String? customerPhone String? totalPriceCents Int? // order's total_price at booking time, for the dispatch dashboard's revenue-by-method view createdAt DateTime @default(now()) @@index([shopDomain, locationId, method, slotStart]) @@index([shopDomain, status]) @@index([shopDomain, zoneId]) } // SlotHold is intentionally NOT a Prisma model — per IMPLEMENTATION_PLAN.md // §4 it's "App DB (Redis-backed)": Redis's native TTL/expiry is exactly the // semantics a soft, time-limited reservation needs, so it's the sole source // of truth for holds (app/services/holds.server.ts). Mirroring it into // Postgres too would only add a sync-consistency burden with no benefit. model Zone { id String @id @default(cuid()) shopDomain String locationId String location Location @relation(fields: [locationId], references: [id], onDelete: Cascade) name String type String // postal|radius postalCodes String[] // used when type = postal radiusKm Float? // used when type = radius (straight-line distance from the location) minOrders Int? // delivery-density threshold: don't offer this zone's days until N orders already routed there active Boolean @default(true) rates Rate[] createdAt DateTime @default(now()) @@index([shopDomain, locationId]) } model Rate { id String @id @default(cuid()) shopDomain String method Method zoneId String? zone Zone? @relation(fields: [zoneId], references: [id], onDelete: Cascade) name String // shown to the shopper, e.g. "Standard local delivery" priceCents Int keyedBy String // zone|distance — see rates.server.ts for resolution order minDistanceKm Float? // used when keyedBy = distance maxDistanceKm Float? // used when keyedBy = distance createdAt DateTime @default(now()) @@index([shopDomain, method]) } // Product/collection/vendor/type/tag-scoped delivery rule (PRODUCT_STRATEGY.md // §2 parity row, previously unimplemented — see product-rules.server.ts). // A cart is matched against every active rule whose scope matches something // in it; matches combine (max lead time, intersected allowed methods/ // locations) rather than the first match winning, so e.g. a "fragile // vendor" rule and a "furniture type" rule on the same cart both apply. model ProductRule { id String @id @default(cuid()) shopDomain String scopeType String // product|collection|vendor|type|tag scopeValue String allowedMethods Method[] // empty = unrestricted (any method allowed) leadTimeMin Int? // extra prep-time buffer this rule imposes, on top of the slot's own leadTimeMin allowedLocationIds String[] // empty = unrestricted (any location allowed) // Slot start-minutes (minutes from local midnight) this rule forbids when a // matching product is in the cart (study §3.5 "block a specific date or time // slot at checkout based on which products are in the cart" — e.g. a fragile // item that can't go on the earliest run). Empty = nothing blocked. blockedStartMins Int[] active Boolean @default(true) createdAt DateTime @default(now()) @@index([shopDomain, scopeType]) } // Permanent cache of address -> lat/lng geocode results (IMPLEMENTATION_PLAN.md // §9: "cache geocode results per address; don't call the maps API on every // availability request"). Addresses don't move, so entries never expire. model GeocodeCache { id String @id @default(cuid()) normalizedKey String @unique // lowercased, whitespace-collapsed address string lat Float lng Float createdAt DateTime @default(now()) } // Same rationale as GeocodeCache, for Google's Distance Matrix API (driving- // distance zones, PRODUCT_STRATEGY.md §2 "radius- or driving-distance-based // eligibility"): road distance between two fixed points doesn't change often // enough to justify calling a paid API on every availability request. // routeKey is a rounded "lat,lng|lat,lng" pair — see zones.server.ts. model DrivingDistanceCache { id String @id @default(cuid()) routeKey String @unique distanceKm Float createdAt DateTime @default(now()) }