// 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 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) // 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) 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]) } // 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()) }