Skip to content

07 — Database Schema & Entity Relationship Diagram

Status: 📐 Target design — no database exists yet. Every entity below is a TypeScript type in src/types/*.ts, backed today by mock arrays plus localStorage. The schema is not a sketch: docs/backend/schema.prisma passes prisma validate, generates 49 tables, and the security layer on top of it (14) was exercised on Postgres 17. §3 embeds that file verbatim, and a unit test fails if the two ever differ.

🟢 বাংলা ভূমিকা: ভবিষ্যতের PostgreSQL ডাটাবেজের পুরো নকশা — ERD, Prisma স্কিমা, প্রতিটা সিদ্ধান্তের কারণ। এটা খসড়া না: স্কিমা ফাইলটা prisma validate পাস করে, ৪৯টা টেবিল তৈরি করে, আর তার উপরের সিকিউরিটি Postgres 17-এ পরীক্ষিত। §৩-এ ফাইলটা হুবহু বসানো, আর দুটো আলাদা হলে একটা টেস্ট ফেইল করে।


1. Design principles

  1. Shared schema, row-level tenantId. One database, every restaurant's rows side by side, isolated by Postgres Row-Level Security — the simplest model to operate at this scale, and the one Supabase is built around. Every aggregate root carries tenantId; child rows (order lines, variants, messages) reach it through their parent, and so do their policies.
  2. The domain types are the contract. Each model maps to a type in src/types/*. Where a column differs from the type (flattened branding, a join instead of tenantName), the model's comment says why — and a per-module mapper is the only code that knows (docs/15 §6).
  3. Opaque string ids (cuid()), never auto-increment — safe in URLs (/promo/[promoId], /staff/[staffId]), mergeable across environments, and the seed keeps the mock ids (tn_swadh_ghar, sg-…, ff-16) so tests and FEATURE.* constants keep pointing at the same rows.
  4. Snapshots where history must not move. An order line copies name, price, tile and photo; a timeline entry keeps the words the staff member saw. Changing the menu tomorrow must not rewrite last night's receipt.
  5. Derived numbers are not columns. MRR, today's orders, ratings, a customer's total spend — computed in queries (§4), so they can never disagree with the rows they come from.
  6. The database enforces what the UI already promises. Closed sets are enums, not strings (VisualToken, DietTag); ranges are CHECKs (spice 0–3, rating 1–5); "one main branch" and "one open sitting per table" are partial unique indexes; order numbers come from a locked counter. Each of these was a UI rule first.
  7. JSON only when a value is read and written whole — addons on an order line, comboItems on a promo, the promo landing design. Never for anything a query filters on.

🟢 বাংলা সারাংশ: নীতিগুলো: (১) একটা ডাটাবেজ, প্রতিটা সারিতে tenantId, RLS দিয়ে আলাদা; (২) src/types-এর টাইপই চুক্তি — যেখানে কলাম আলাদা, সেখানে কারণ লেখা; (৩) cuid() আইডি, সিডে mock আইডি অপরিবর্তিত; (৪) অর্ডার লাইনে নাম-দাম-ছবির snapshot, যাতে কালকের মেনু বদল আজকের রসিদ না বদলায়; (৫) MRR, আজকের অর্ডার, রেটিং — এগুলো কলাম না, কোয়েরিতে হিসাব; (৬) UI যা প্রতিশ্রুতি দেয় ডাটাবেজও তা মানায় — enum, CHECK, partial unique index; (৭) JSON শুধু যা পুরোটা একসাথে পড়া-লেখা হয়।


2. Entity relationship diagram

Split into four views — one diagram with 49 entities is unreadable. Every table appears in exactly one view; TENANT appears in all of them.

2.1 The core loop — scan, sit, order, pay

Entity relationship diagram

2.2 Menu, packages, inventory

Entity relationship diagram

2.3 People, customers, marketing

Entity relationship diagram

2.4 Platform — plans, billing, flags, support

Entity relationship diagram

SubscriptionPlan.featureFlagIds is a list of FEATURE_FLAG ids rather than a join table on purpose — see the comment on the model and §3.4.

🟢 বাংলা সারাংশ: ৪৯টা এন্টিটি এক ডায়াগ্রামে পড়া যায় না, তাই চারটা ভাগ: (১) মূল লুপ — টেবিল, সেশন, অর্ডার, পেমেন্ট; (২) মেনু, প্যাকেজ, ইনভেন্টরি; (৩) মানুষ, কাস্টমার, মার্কেটিং; (৪) প্ল্যাটফর্ম — প্ল্যান, বিলিং, ফিচার ফ্ল্যাগ, সাপোর্ট। প্রতিটা টেবিল ঠিক একটা ভাগে আছে; TENANT সবগুলোতে।


3. Prisma schema

3.1 Conventions

ConventionWhy
Tables snake_case plural via @@map; columns keep Prisma's camelCaseReadable in the Supabase dashboard; hand-written SQL quotes columns ("tenantId") — policies.sql does
Hyphenated TS values use enum @map (dine_in @map("dine-in"))The database stores exactly what src/types stores. Prisma Client returns dine_in; the mapper converts — the one place the spellings meet
Money Decimal(12,2), stock Decimal(12,3)Exact arithmetic. Prisma returns Decimal objects, which can't be passed to a client component — mappers return number
Timestamps UTC; restaurant-local rules evaluated in Tenant.timezone"Lunch 12–15" means Dhaka's noon, not the server's
onDelete: Cascade from tenant; Restrict where deleting would orphan money or a recipe; SetNull for optional referencesDeleting a tenant removes it cleanly; deleting a dish that a package or recipe uses is refused instead of silently breaking the package
datasource.url pooled, directUrl directSupavisor transaction pooling for runtime; migrations need a direct session

3.2 Why visualToken and images are separate

A single imageUrl String is the obvious shape and the wrong one. Every dish needs something to render from the moment an owner types its name — forty dishes and no photos still has to look like a finished menu, not forty broken images. So visualToken is required and drives a gradient + icon, while images (Storage paths) layer over it when the vendor uploads their own. The frontend once carried the wrong shape — a String named imageUrl holding values like "grill" — and the cost was invisible failure: a typo and a genuine URL both resolved to the neutral grey tile, with nothing reporting it. As a Postgres enum the same mistake is a constraint violation. Order lines snapshot both, for the same reason they snapshot name and unitPrice.

3.3 What changed from the earlier draft of this document

The first version of this schema was written before half the product existed. Rebuilding it from src/types found these, any one of which would have surfaced as a failed migration or a silent data loss:

Earlier draftNowWhy it mattered
Zone related to a model DiningTable that didn't exist; tables still used a TableZone enumZone rows ← RestaurantTable.zoneIdDidn't validate; and the enum contradicted owner-defined zones
MenuItem.image String?images String[]The code supports several photos per dish (bulk upload, slider)
No meal packagesMealPackage + items + choice groups/options + scheduleBuilt, shipped, behind FEATURE.packages
Order had no branchId, tableLabel, sessionId, clientRequestId; lines had no packageContentsaddedMulti-branch, receipts, sittings, offline retry, kitchen plating
Timeline had only labellabelKey + labelSeeded events are translated; recorded ones keep their words
Hyphenated values as enum identifiers (out_for_delivery) with no mapping@map("out-for-delivery")The database would have stored different strings than the app
TableCall related to Tenant without a back-relationfixedDidn't validate
No TableSession, StaffInvite, CustomDomain, PlatformUser, Payment, OrderCounter, notifications, promo design, invoice emailsaddedEach is a screen that exists today
Promo code @unique globally@@unique([tenantId, code])Two restaurants may both run "EID30"
Tenant had a plan enum and a separate subscriptionPlanIdplanId → SubscriptionPlanCustom plans have generated ids; two sources of truth disagreed
Invoice.pdfUrlpdfPath (bucket-relative)Signed URLs expire; store the path

3.4 Every table, drawn with its columns

The same schema as §3.5, as pictures: one diagram per view from §2, every column with its type and key. Click the expand icon on a diagram to open it full screen.

Generated from docs/backend/schema.prisma by node scripts/schema-erd.mjs — do not edit by hand; a unit test fails if it goes stale. PK primary key · FK foreign key · UK unique. Boxes with no columns belong to another view.

3.4.1 Core loop — floor, sittings, orders, payments

Entity relationship diagram

3.4.2 Menu, packages, inventory

Entity relationship diagram

3.4.3 People, customers, marketing

Entity relationship diagram

3.4.4 Platform — plans, billing, flags, support

Entity relationship diagram

3.5 The full schema (text)

// RestaurantOS — target production schema (Supabase Postgres, Prisma 6).
//
// NOT WIRED UP. This file lives under docs/ on purpose: the project is in its
// frontend-only phase (CLAUDE.md, Rule 1), so there is no `prisma` dependency
// and nothing imports a client. It is kept as a real, `prisma validate`-clean
// file rather than a code block so it cannot silently rot — docs/07 embeds it
// verbatim, and `src/lib/backend-docs.test.ts` fails if the two drift.
//
// On day one of backend work: `mkdir prisma && git mv docs/backend/schema.prisma prisma/`
// (see docs/16 §2). Every model maps 1:1 to a type in `src/types/*`; where it
// does not, the comment on the model says why.
//
// Conventions (docs/07 §3):
//   • Table names are snake_case plurals (@@map); columns keep Prisma's camelCase,
//     so hand-written SQL quotes them: "tenantId".
//   • Every aggregate root carries "tenantId". Child rows (order lines, variants…)
//     reach their tenant through the parent; RLS follows the same path.
//   • Enum values that contain a hyphen in TypeScript use @map, so the database
//     stores exactly what src/types stores ("dine-in", "out-for-delivery").
//     Prisma Client returns the identifier (dine_in); the per-module mapper
//     converts. That conversion is the one place the two spellings meet.
//   • Money is Decimal(12,2). Prisma returns Decimal objects, which cannot cross
//     into a client component — the mapper turns them into numbers.
//   • Ids are cuid() strings. Seeded rows keep their mock ids ("sg-…", "tn_…").
//   • Derived numbers the UI shows (Tenant.mrr, Branch.ordersToday,
//     CrmCustomer.totalSpent, StaffMember.shiftsThisWeek…) are NOT columns —
//     they are computed in queries. docs/07 §4 lists each one and its source.

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL") // pooled (Supavisor, port 6543, ?pgbouncer=true)
  directUrl = env("DIRECT_URL") // direct (port 5432) — migrations only
}

// ═══════════════════════════════ Enums ═══════════════════════════════

enum TenantStatus {
  active
  trial
  suspended
  churned
}

enum StorefrontLayoutWidth {
  boxed
  full
}

/// What kind of food or venue a tile depicts. Closed on purpose: as free text,
/// a typo or a real URL both rendered as a silent grey tile (docs/07 §3.2).
enum VisualToken {
  starter
  biryani
  curry
  grill
  bread
  dessert
  beverage
  seafood
  salad
  soup
  pizza
  burger
  combo
  restaurant
  rice
}

enum DietTag {
  veg
  vegan
  halal
  gluten_free @map("gluten-free")
  dairy_free  @map("dairy-free")
  nut_free    @map("nut-free")
}

enum DomainStatus {
  pending
  verified
}

enum BranchStatus {
  open
  closed
}

enum TableStatus {
  available
  occupied
  reserved
  needs_cleaning @map("needs-cleaning")
  bill_requested @map("bill-requested")
}

enum TableCallType {
  call_waiter  @map("call-waiter")
  request_bill @map("request-bill")
}

enum StaffRole {
  owner
  manager
  waiter
  chef
  kitchen
  rider
  cashier
}

enum StaffStatus {
  online
  offline
  on_break @map("on-break")
}

enum RiderVehicle {
  bike
  scooter
  car
  bicycle
}

enum OrderStatus {
  pending
  confirmed
  preparing
  ready
  served
  out_for_delivery @map("out-for-delivery")
  delivered
  completed
  cancelled
}

enum OrderChannel {
  dine_in  @map("dine-in")
  takeaway
  delivery
}

enum KitchenStation {
  grill
  fry
  cold
  bar
  dessert
  curry
}

enum PaymentMethod {
  cash
  card
  wallet
}

enum PaymentProvider {
  cash
  card_terminal @map("card-terminal")
  bkash
  nagad
  stripe
}

enum PaymentKind {
  charge
  refund
}

enum PaymentState {
  pending
  succeeded
  failed
}

enum ReservationStatus {
  pending
  confirmed
  seated
  completed
  cancelled
  no_show   @map("no-show")
}

enum CustomerSegment {
  vip
  regular
  new
  at_risk @map("at-risk")
}

enum LoyaltyTier {
  bronze
  silver
  gold
}

enum LoyaltyTransactionType {
  earn
  redeem
  bonus
}

enum ReviewSentiment {
  positive
  neutral
  negative
}

enum StockUnit {
  kg
  g
  l
  ml
  pcs
}

enum StockMovementType {
  purchase
  sale
  adjustment
  waste
  return
  transfer
}

enum PurchaseOrderStatus {
  draft
  sent
  partially_received @map("partially-received")
  received
  cancelled
}

enum SupportTicketStatus {
  open
  pending
  resolved
  closed
}

enum SupportTicketPriority {
  low
  medium
  high
  urgent
}

enum SupportTicketCategory {
  billing
  technical
  onboarding
  feature_request @map("feature-request")
  other
}

enum SupportAuthorRole {
  tenant
  platform
}

enum AuditLogScope {
  platform
  tenant
}

enum FeatureFlagPhase {
  MVP
  P2
  P3
  P4
}

enum BillingCycle {
  monthly
  yearly
}

enum InvoiceStatus {
  paid
  pending
  overdue
  failed
}

enum InvoiceReason {
  registration
  renewal
  manual
}

enum VipOverrideType {
  free_lifetime    @map("free-lifetime")
  percent_discount @map("percent-discount")
  fixed_price      @map("fixed-price")
}

enum PlatformUserRole {
  super_admin @map("super-admin")
  support
}

enum PlatformUserStatus {
  active
  invited
  suspended
}

enum NotificationChannel {
  sms
  whatsapp
  push
  email
}

enum NotificationEvent {
  order_placed     @map("order-placed")
  kitchen_dispatch @map("kitchen-dispatch")
  out_for_delivery @map("out-for-delivery")
  delivered
  bill_ready       @map("bill-ready")
}

enum NotificationDispatchStatus {
  sent
  delivered
  failed
}

// ═══════════════════════════════ Platform & tenant ═══════════════════════════════

/// One restaurant. `slug` is its subdomain and the key every tenant-scoped read
/// takes first (src/lib/data/tenant.ts). TenantBranding and TenantAddress are
/// flattened into columns: they are always read with the tenant, never alone.
model Tenant {
  id             String                @id @default(cuid())
  slug           String                @unique
  name           String
  cuisine        String
  cuisineBn      String?
  status         TenantStatus          @default(trial)
  planId         String
  plan           SubscriptionPlan      @relation(fields: [planId], references: [id])
  // branding
  logoToken      VisualToken
  logo           String? // Storage path in the `branding` bucket
  coverToken     VisualToken
  coverImage     String?
  accent         String // hex; read by accentInitScript before first paint
  tagline        String
  taglineBn      String?
  layoutWidth    StorefrontLayoutWidth @default(boxed)
  // address
  addressLine1   String
  addressCity    String
  addressCountry String
  // locale & money
  currency       String                @default("BDT")
  currencySymbol String                @default("৳")
  timezone       String                @default("Asia/Dhaka")
  // onboarding checklist facts (ros-qr-printed)
  qrPrintedAt    DateTime?
  createdAt      DateTime              @default(now())
  updatedAt      DateTime              @updatedAt

  customDomain      CustomDomain?
  branches          Branch[]
  zones             Zone[]
  tables            RestaurantTable[]
  tableSessions     TableSession[]
  tableCalls        TableCall[]
  menuCategories    MenuCategory[]
  menuItems         MenuItem[]
  packages          MealPackage[]
  staff             StaffMember[]
  staffInvites      StaffInvite[]
  orders            Order[]
  orderCounter      OrderCounter?
  payments          Payment[]
  promos            Promo[]
  promoDesign       PromoLandingDesign?
  customers         CrmCustomer[]
  loyaltyAccounts   LoyaltyAccount[]
  reviews           Review[]
  reservations      Reservation[]
  inventoryItems    InventoryItem[]
  stockMovements    StockMovement[]
  suppliers         Supplier[]
  purchaseOrders    PurchaseOrder[]
  deliveryZones     DeliveryZone[]
  supportTickets    SupportTicket[]
  auditLogEntries   AuditLogEntry[]
  featureOverrides  TenantFeatureOverride[]
  vipOverride       VipOverride?
  invoices          Invoice[]
  notificationTpls  NotificationTemplate[]
  notificationSends NotificationDispatch[]

  @@index([planId])
  @@map("tenants")
}

/// ros-custom-domain. A separate row rather than Tenant.customDomain because it
/// has a lifecycle (pending → verified) and src/proxy.ts must only route a
/// hostname once it is verified — docs/13 §6.
model CustomDomain {
  id            String       @id @default(cuid())
  tenantId      String       @unique
  tenant        Tenant       @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  domain        String       @unique // lower-cased, no scheme, no trailing dot
  status        DomainStatus @default(pending)
  requestedAt   DateTime     @default(now())
  verifiedAt    DateTime?
  lastCheckedAt DateTime?

  @@index([status])
  @@map("custom_domains")
}

model Branch {
  id       String       @id @default(cuid())
  tenantId String
  tenant   Tenant       @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  name     String
  address  String
  city     String
  phone    String // validated as a Bangladeshi mobile (src/lib/branch-draft.ts)
  isMain   Boolean      @default(false)
  status   BranchStatus @default(open)

  tables RestaurantTable[]
  orders Order[]

  @@index([tenantId])
  @@map("branches")
}

// ═══════════════════════════════ Floor, tables & QR ═══════════════════════════════

/// Zones are rows, not an enum: a restaurant's areas are its own business.
/// `labelKey` is set only on the five seeded zones so they render in the
/// guest's language; `label` is the owner's own words and is never translated.
/// Replaces ros-floor-plan's customZones / hiddenZoneIds / renamedZones.
model Zone {
  id        String  @id @default(cuid())
  tenantId  String
  tenant    Tenant  @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  labelKey  String?
  label     String?
  isHidden  Boolean @default(false)
  sortOrder Int     @default(0)

  tables RestaurantTable[]

  @@index([tenantId])
  @@map("zones")
}

/// `qrUrl` is not stored — it is `/table?table=<id>&token=<qrToken>`, built by
/// the mapper. Rotating `qrToken` (ros-table-tokens) invalidates every old print
/// without changing the stable id the floor plan and orders refer to.
model RestaurantTable {
  id               String      @id @default(cuid())
  tenantId         String
  tenant           Tenant      @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  branchId         String?
  branch           Branch?     @relation(fields: [branchId], references: [id], onDelete: SetNull)
  zoneId           String
  zone             Zone        @relation(fields: [zoneId], references: [id])
  label            String // "T-12"
  capacity         Int
  status           TableStatus @default(available)
  currentOrderId   String?
  occupiedSince    DateTime?
  qrToken          String      @unique
  qrTokenRotatedAt DateTime    @default(now())
  floorX           Float       @default(0) // ros-floor-plan positions, 0–1 of the canvas
  floorY           Float       @default(0)

  sessions         TableSession[]
  calls            TableCall[]
  orders           Order[]
  reservations     Reservation[]
  staffAssignments StaffTableAssignment[]

  @@unique([tenantId, label])
  @@index([tenantId, zoneId])
  @@map("restaurant_tables")
}

/// One party's sitting at a table (src/lib/table-session.ts). Idle for
/// SESSION_IDLE_MS or ended by a waiter clearing the table → the next scan
/// starts a new one, so a new party never inherits the last one's cart.
model TableSession {
  id             String          @id @default(cuid())
  tenantId       String
  tenant         Tenant          @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  tableId        String
  table          RestaurantTable @relation(fields: [tableId], references: [id], onDelete: Cascade)
  startedAt      DateTime        @default(now())
  lastActivityAt DateTime        @default(now())
  endedAt        DateTime?

  orders Order[]

  @@index([tableId, endedAt])
  @@map("table_sessions")
}

model TableCall {
  id         String          @id @default(cuid())
  tenantId   String
  tenant     Tenant          @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  tableId    String
  table      RestaurantTable @relation(fields: [tableId], references: [id], onDelete: Cascade)
  type       TableCallType
  waiterId   String?
  waiter     StaffMember?    @relation(fields: [waiterId], references: [id], onDelete: SetNull)
  createdAt  DateTime        @default(now())
  resolved   Boolean         @default(false)
  resolvedAt DateTime?

  @@index([tenantId, resolved])
  @@map("table_calls")
}

// ═══════════════════════════════ Menu ═══════════════════════════════

model MenuCategory {
  id          String  @id @default(cuid())
  tenantId    String
  tenant      Tenant  @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  name        String
  nameBn      String? // Bengali; the guest menu falls back to name
  icon        String // lucide icon name
  description String?
  sortOrder   Int     @default(0)

  items MenuItem[]

  @@index([tenantId, sortOrder])
  @@map("menu_categories")
}

/// `visualToken` is required and always renders something; `images` are the
/// vendor's own photos (Storage paths, ros-menu-images) layered over it.
/// `allergens` is what the allergy filter reads — it must be complete, because
/// a guest excluding nuts trusts that a hidden dish is the only unsafe one.
model MenuItem {
  id              String       @id @default(cuid())
  tenantId        String
  tenant          Tenant       @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  categoryId      String
  category        MenuCategory @relation(fields: [categoryId], references: [id])
  name            String
  nameBn          String? // Bengali; the guest menu falls back to name
  description     String
  descriptionBn   String? // Bengali; falls back to description
  price           Decimal      @db.Decimal(12, 2)
  visualToken     VisualToken
  images          String[]     @default([])
  dietTags        DietTag[]    @default([])
  spiceLevel      Int          @default(0) // 0–3, CHECK in docs/backend/supabase/policies.sql
  isPopular       Boolean      @default(false)
  isNew           Boolean      @default(false)
  isChefSpecial   Boolean      @default(false)
  allergens       String[]     @default([])
  rating          Decimal      @default(0) @db.Decimal(2, 1) // rolled up from reviews
  ratingCount     Int          @default(0)
  prepTimeMinutes Int
  calories        Int?
  available       Boolean      @default(true)
  sortOrder       Int          @default(0)

  variants             MenuItemVariant[]
  addons               MenuItemAddon[]
  ingredients          MenuItemIngredient[]
  packageItems         MealPackageItem[]
  packageChoiceOptions MealPackageChoiceOption[]

  @@index([tenantId, categoryId])
  @@map("menu_items")
}

model MenuItemVariant {
  id         String   @id @default(cuid())
  menuItemId String
  menuItem   MenuItem @relation(fields: [menuItemId], references: [id], onDelete: Cascade)
  label      String
  priceDelta Decimal  @db.Decimal(12, 2)
  sortOrder  Int      @default(0)

  @@index([menuItemId])
  @@map("menu_item_variants")
}

model MenuItemAddon {
  id         String   @id @default(cuid())
  menuItemId String
  menuItem   MenuItem @relation(fields: [menuItemId], references: [id], onDelete: Cascade)
  label      String
  price      Decimal  @db.Decimal(12, 2)

  @@index([menuItemId])
  @@map("menu_item_addons")
}

/// Bill of materials: how much of an inventory item one serving consumes.
/// The costing table in analytics reads this; so will automatic stock decrement.
model MenuItemIngredient {
  id                 String        @id @default(cuid())
  menuItemId         String
  menuItem           MenuItem      @relation(fields: [menuItemId], references: [id], onDelete: Cascade)
  inventoryItemId    String
  inventoryItem      InventoryItem @relation(fields: [inventoryItemId], references: [id], onDelete: Restrict)
  quantityPerServing Decimal       @db.Decimal(12, 3)

  @@unique([menuItemId, inventoryItemId])
  @@map("menu_item_ingredients")
}

// ═══════════════════════════════ Meal packages (FEATURE.packages) ═══════════════════════════════

/// ros-packages (`added` + `hiddenIds` become rows + isActive). Schedule is
/// flattened from PackageSchedule: empty days = every day; null from/to = all day.
/// The window is evaluated in Tenant.timezone, never the server's.
model MealPackage {
  id            String      @id @default(cuid())
  tenantId      String
  tenant        Tenant      @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  name          String
  description   String
  nameBn        String?
  descriptionBn String?
  visualToken   VisualToken
  images        String[]    @default([])
  price         Decimal     @db.Decimal(12, 2)
  serves        Int         @default(1)
  isActive      Boolean     @default(true)
  scheduleDays  Int[]       @default([]) // 0 = Sunday … 6 = Saturday
  scheduleFrom  String? // "HH:mm"
  scheduleTo    String? // "HH:mm"; may be earlier than from (crosses midnight)
  sortOrder     Int         @default(0)
  createdAt     DateTime    @default(now())

  items   MealPackageItem[]
  choices MealPackageChoiceGroup[]

  @@index([tenantId, isActive])
  @@map("meal_packages")
}

model MealPackageItem {
  id         String      @id @default(cuid())
  packageId  String
  package    MealPackage @relation(fields: [packageId], references: [id], onDelete: Cascade)
  menuItemId String
  menuItem   MenuItem    @relation(fields: [menuItemId], references: [id], onDelete: Restrict)
  quantity   Int         @default(1)

  @@unique([packageId, menuItemId])
  @@map("meal_package_items")
}

/// "Choose N of these". `pick` ≥ 1 and ≤ options count (CHECK in SQL).
model MealPackageChoiceGroup {
  id        String      @id @default(cuid())
  packageId String
  package   MealPackage @relation(fields: [packageId], references: [id], onDelete: Cascade)
  label     String
  pick      Int
  sortOrder Int         @default(0)

  options MealPackageChoiceOption[]

  @@index([packageId])
  @@map("meal_package_choice_groups")
}

model MealPackageChoiceOption {
  id         String                 @id @default(cuid())
  groupId    String
  group      MealPackageChoiceGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
  menuItemId String
  menuItem   MenuItem               @relation(fields: [menuItemId], references: [id], onDelete: Restrict)
  priceDelta Decimal                @default(0) @db.Decimal(12, 2)

  @@unique([groupId, menuItemId])
  @@map("meal_package_choice_options")
}

// ═══════════════════════════════ Staff & identity ═══════════════════════════════

/// A person on one restaurant's team. `userId` links to Supabase `auth.users`
/// when they sign in with email (owner, manager); shift staff on a shared device
/// may have no account at all and sign in by PIN — hence nullable. One auth user
/// can belong to several tenants (an owner with two restaurants) as two rows.
model StaffMember {
  id                   String        @id @default(cuid())
  tenantId             String
  tenant               Tenant        @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  userId               String?       @db.Uuid
  name                 String
  avatarUrl            String?
  role                 StaffRole
  status               StaffStatus   @default(offline)
  email                String
  phone                String
  pinHash              String? // argon2id of the 4-digit shift PIN; never the PIN
  joinedAt             DateTime      @default(now())
  deactivatedAt        DateTime?
  // rider-only
  vehicle              RiderVehicle?
  currentLocationLabel String?

  tableAssignments StaffTableAssignment[]
  waiterOrders     Order[]                @relation("WaiterOrders")
  riderOrders      Order[]                @relation("RiderOrders")
  tableCalls       TableCall[]
  stockMovements   StockMovement[]

  @@unique([tenantId, email])
  @@unique([tenantId, userId])
  @@index([tenantId, role])
  @@map("staff_members")
}

/// StaffMember.assignedTables[] as a join table.
model StaffTableAssignment {
  staffId String
  staff   StaffMember     @relation(fields: [staffId], references: [id], onDelete: Cascade)
  tableId String
  table   RestaurantTable @relation(fields: [tableId], references: [id], onDelete: Cascade)

  @@id([staffId, tableId])
  @@map("staff_table_assignments")
}

/// ros-staff-invites. `tokenHash` is the SHA-256 of the link's token; the raw
/// token exists only in the email.
model StaffInvite {
  id         String    @id @default(cuid())
  tenantId   String
  tenant     Tenant    @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  name       String
  email      String
  role       StaffRole
  tokenHash  String    @unique
  invitedAt  DateTime  @default(now())
  expiresAt  DateTime
  acceptedAt DateTime?
  revokedAt  DateTime?

  @@index([tenantId, email])
  @@map("staff_invites")
}

/// Platform operators. `id` IS the Supabase auth.users id — a platform user
/// always has an account, unlike shift staff.
model PlatformUser {
  id           String             @id @db.Uuid
  name         String
  email        String             @unique
  avatarUrl    String?
  role         PlatformUserRole
  status       PlatformUserStatus @default(invited)
  permissions  String[]           @default([])
  lastActiveAt DateTime?
  createdAt    DateTime           @default(now())

  @@map("platform_users")
}

// ═══════════════════════════════ Orders & payments ═══════════════════════════════

/// ros-live-orders. `orderNumber` is issued by OrderCounter inside the placing
/// transaction — never by the client (the cart used to compute it). The server
/// recomputes every total with src/lib/order-totals.ts; the client's numbers
/// are a display hint only. `clientRequestId` makes a retried submit (the
/// offline service worker, a double tap) return the first order, not a second.
model Order {
  id              String           @id @default(cuid())
  tenantId        String
  tenant          Tenant           @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  orderNumber     String
  clientRequestId String?
  branchId        String?
  branch          Branch?          @relation(fields: [branchId], references: [id], onDelete: SetNull)
  tableId         String?
  table           RestaurantTable? @relation(fields: [tableId], references: [id], onDelete: SetNull)
  tableLabel      String? // snapshot — the table may be renamed later
  sessionId       String?
  session         TableSession?    @relation(fields: [sessionId], references: [id], onDelete: SetNull)
  channel         OrderChannel
  status          OrderStatus      @default(pending)
  station         KitchenStation?
  subtotal        Decimal          @db.Decimal(12, 2)
  tax             Decimal          @db.Decimal(12, 2)
  serviceFee      Decimal          @db.Decimal(12, 2)
  deliveryFee     Decimal?         @db.Decimal(12, 2)
  discount        Decimal?         @db.Decimal(12, 2)
  /// The guest's tip for the staff. Never reduced by a discount, and never
  /// part of the restaurant's own revenue — analytics must exclude it.
  tip             Decimal?         @db.Decimal(12, 2)
  total           Decimal          @db.Decimal(12, 2)
  promoId         String?
  promo           Promo?           @relation(fields: [promoId], references: [id], onDelete: SetNull)
  promoCode       String? // snapshot of what the guest typed
  customerId      String?
  customer        CrmCustomer?     @relation(fields: [customerId], references: [id], onDelete: SetNull)
  customerName    String?
  customerPhone   String?
  locale          String           @default("en") // the language the guest ordered in — picks the SMS/WhatsApp template
  deliveryAddress String?
  deliveryZoneId  String?
  riderId         String?
  rider           StaffMember?     @relation("RiderOrders", fields: [riderId], references: [id], onDelete: SetNull)
  waiterId        String?
  waiter          StaffMember?     @relation("WaiterOrders", fields: [waiterId], references: [id], onDelete: SetNull)
  notes           String?
  paymentMethod   PaymentMethod?
  isPaid          Boolean          @default(false)
  billRequested   Boolean          @default(false)
  createdAt       DateTime         @default(now())
  updatedAt       DateTime         @updatedAt

  items    OrderLineItem[]
  timeline OrderTimelineEvent[]
  payments Payment[]
  review   Review?

  @@unique([tenantId, orderNumber])
  @@unique([tenantId, clientRequestId])
  @@index([tenantId, status])
  @@index([tenantId, createdAt])
  @@index([sessionId])
  @@map("orders")
}

/// Per-tenant order number sequence: app.next_order_number() upserts and
/// returns prefix || next ("#1001", the format the mock data and receipts
/// already use) inside the order transaction — a row lock, so concurrent
/// guests get consecutive numbers, never the same one.
model OrderCounter {
  tenantId String @id
  tenant   Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  prefix   String @default("#")
  next     Int    @default(1001)

  @@map("order_counters")
}

/// A snapshot. Name, price, tile and photo are copied at order time so a
/// receipt keeps showing what was actually ordered after the menu changes —
/// which is also why `menuItemId` is a plain column, not a foreign key.
model OrderLineItem {
  id              String      @id @default(cuid())
  orderId         String
  order           Order       @relation(fields: [orderId], references: [id], onDelete: Cascade)
  menuItemId      String // or a package id when packageId is set
  packageId       String?
  name            String
  visualToken     VisualToken
  image           String?
  quantity        Int
  unitPrice       Decimal     @db.Decimal(12, 2)
  variantLabel    String?
  addons          Json        @default("[]") // [{ label, price }]
  notes           String?
  packageContents String[]    @default([]) // "2× Bhat", "Chicken Curry" — what the kitchen plates
  servedAt        DateTime? // waiter's per-item served mark

  @@index([orderId])
  @@map("order_line_items")
}

/// Recorded history: `label` stays in the language the dashboard was in when
/// it happened (CLAUDE.md Rule 3). Seeded/system events use `labelKey`.
model OrderTimelineEvent {
  id        String      @id @default(cuid())
  orderId   String
  order     Order       @relation(fields: [orderId], references: [id], onDelete: Cascade)
  status    OrderStatus
  labelKey  String?
  label     String?
  actorId   String? // staff member id, when a person did it
  timestamp DateTime    @default(now())

  @@index([orderId, timestamp])
  @@map("order_timeline_events")
}

/// Money movements. Gateways confirm by webhook (docs/08 §1.1); `providerRef`
/// is unique so a replayed IPN is a no-op. A refund is its own row.
model Payment {
  id          String          @id @default(cuid())
  tenantId    String
  tenant      Tenant          @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  orderId     String
  order       Order           @relation(fields: [orderId], references: [id], onDelete: Restrict)
  kind        PaymentKind     @default(charge)
  provider    PaymentProvider
  state       PaymentState    @default(pending)
  amount      Decimal         @db.Decimal(12, 2)
  providerRef String?         @unique
  raw         Json?
  createdAt   DateTime        @default(now())
  settledAt   DateTime?

  @@index([tenantId, createdAt])
  @@index([orderId])
  @@map("payments")
}

// ═══════════════════════════════ Promos ═══════════════════════════════

/// ros-promos (`extra` + `overrides` become rows). `code` is unique per tenant,
/// not globally — two restaurants may both run "EID30".
model Promo {
  id              String      @id @default(cuid())
  tenantId        String
  tenant          Tenant      @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  title           String
  subtitle        String
  description     String
  bannerToken     VisualToken
  bannerImage     String?
  discountLabel   String
  originalPrice   Decimal     @db.Decimal(12, 2)
  discountedPrice Decimal     @db.Decimal(12, 2)
  comboItems      Json        @default("[]") // PromoComboItem[] — owner-typed display lines, not dish refs
  startsAt        DateTime
  endsAt          DateTime
  code            String
  claimedCount    Int         @default(0)
  isActive        Boolean     @default(true)

  orders Order[]

  @@unique([tenantId, code])
  @@index([tenantId, isActive])
  @@map("promos")
}

/// ros-promo-design: the promo landing CMS (PromoLandingDesign). One per tenant.
/// JSON because it is read and written whole, never queried into.
model PromoLandingDesign {
  tenantId  String   @id
  tenant    Tenant   @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  design    Json
  updatedAt DateTime @updatedAt

  @@map("promo_landing_designs")
}

// ═══════════════════════════════ CRM, loyalty, reviews ═══════════════════════════════

/// Phone is the identity: guests never make accounts, they give a number.
/// totalOrders / totalSpent / lastOrderAt are aggregates over orders, not columns.
model CrmCustomer {
  id           String          @id @default(cuid())
  tenantId     String
  tenant       Tenant          @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  name         String
  phone        String // E.164, +8801…
  email        String?
  favoriteItem String?
  segment      CustomerSegment @default(new)
  createdAt    DateTime        @default(now())

  orders  Order[]
  loyalty LoyaltyAccount?

  @@unique([tenantId, phone])
  @@map("crm_customers")
}

/// ros-loyalty. Points are a ledger: `points` is a cached SUM of transactions,
/// updated in the same transaction that inserts one.
model LoyaltyAccount {
  id             String      @id @default(cuid())
  tenantId       String
  tenant         Tenant      @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  customerId     String      @unique
  customer       CrmCustomer @relation(fields: [customerId], references: [id], onDelete: Cascade)
  points         Int         @default(0)
  lifetimePoints Int         @default(0)
  tier           LoyaltyTier @default(bronze)

  transactions LoyaltyTransaction[]

  @@index([tenantId])
  @@map("loyalty_accounts")
}

model LoyaltyTransaction {
  id          String                 @id @default(cuid())
  accountId   String
  account     LoyaltyAccount         @relation(fields: [accountId], references: [id], onDelete: Cascade)
  type        LoyaltyTransactionType
  points      Int // negative for redeem
  orderId     String?
  description String
  createdAt   DateTime               @default(now())

  @@index([accountId, createdAt])
  @@map("loyalty_transactions")
}

model Review {
  id           String          @id @default(cuid())
  tenantId     String
  tenant       Tenant          @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  orderId      String?         @unique
  order        Order?          @relation(fields: [orderId], references: [id], onDelete: SetNull)
  customerName String?
  rating       Int // 1–5, CHECK in SQL
  tags         String[]        @default([])
  comment      String?
  sentiment    ReviewSentiment
  channel      OrderChannel
  createdAt    DateTime        @default(now())

  @@index([tenantId, createdAt])
  @@map("reviews")
}

// ═══════════════════════════════ Reservations ═══════════════════════════════

model Reservation {
  id            String            @id @default(cuid())
  tenantId      String
  tenant        Tenant            @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  customerName  String
  customerPhone String
  partySize     Int
  date          DateTime          @db.Date
  time          String // "HH:mm" in Tenant.timezone
  status        ReservationStatus @default(pending)
  tableId       String?
  table         RestaurantTable?  @relation(fields: [tableId], references: [id], onDelete: SetNull)
  notes         String?
  createdAt     DateTime          @default(now())

  @@index([tenantId, date])
  @@map("reservations")
}

// ═══════════════════════════════ Inventory ═══════════════════════════════

model InventoryItem {
  id           String    @id @default(cuid())
  tenantId     String
  tenant       Tenant    @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  name         String
  unit         StockUnit
  currentStock Decimal   @db.Decimal(12, 3)
  minimumStock Decimal   @db.Decimal(12, 3)
  costPerUnit  Decimal   @db.Decimal(12, 2)
  supplierId   String?
  supplier     Supplier? @relation(fields: [supplierId], references: [id], onDelete: SetNull)
  category     String

  movements     StockMovement[]
  usedInItems   MenuItemIngredient[]
  purchaseLines PurchaseOrderLine[]

  @@index([tenantId])
  @@map("inventory_items")
}

/// Carries tenantId itself (not only via the item) because the dashboard lists
/// movements across all items: getStockMovements(slug).
model StockMovement {
  id          String            @id @default(cuid())
  tenantId    String
  tenant      Tenant            @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  itemId      String
  item        InventoryItem     @relation(fields: [itemId], references: [id], onDelete: Cascade)
  type        StockMovementType
  quantity    Decimal           @db.Decimal(12, 3)
  note        String?
  createdById String?
  createdBy   StaffMember?      @relation(fields: [createdById], references: [id], onDelete: SetNull)
  createdAt   DateTime          @default(now())

  @@index([tenantId, createdAt])
  @@map("stock_movements")
}

/// Supplier.itemsSupplied is derived: InventoryItem.supplierId = this id.
model Supplier {
  id          String @id @default(cuid())
  tenantId    String
  tenant      Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  name        String
  contactName String
  phone       String
  email       String

  items          InventoryItem[]
  purchaseOrders PurchaseOrder[]

  @@index([tenantId])
  @@map("suppliers")
}

model PurchaseOrder {
  id         String              @id @default(cuid())
  tenantId   String
  tenant     Tenant              @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  supplierId String
  supplier   Supplier            @relation(fields: [supplierId], references: [id], onDelete: Restrict)
  status     PurchaseOrderStatus @default(draft)
  total      Decimal             @db.Decimal(12, 2)
  createdAt  DateTime            @default(now())
  expectedAt DateTime?

  lines PurchaseOrderLine[]

  @@index([tenantId, createdAt])
  @@map("purchase_orders")
}

model PurchaseOrderLine {
  id              String        @id @default(cuid())
  purchaseOrderId String
  purchaseOrder   PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
  itemId          String
  item            InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
  quantity        Decimal       @db.Decimal(12, 3)
  unitCost        Decimal       @db.Decimal(12, 2)

  @@index([purchaseOrderId])
  @@map("purchase_order_lines")
}

// ═══════════════════════════════ Delivery ═══════════════════════════════

model DeliveryZone {
  id                    String   @id @default(cuid())
  tenantId              String
  tenant                Tenant   @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  name                  String
  fee                   Decimal  @db.Decimal(12, 2)
  minOrder              Decimal  @db.Decimal(12, 2)
  estimatedMinutes      Int
  freeDeliveryThreshold Decimal? @db.Decimal(12, 2)

  @@index([tenantId])
  @@map("delivery_zones")
}

// ═══════════════════════════════ Support, audit, notifications ═══════════════════════════════

model SupportTicket {
  id             String                @id @default(cuid())
  tenantId       String
  tenant         Tenant                @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  subject        String
  requesterName  String
  requesterEmail String
  category       SupportTicketCategory
  priority       SupportTicketPriority @default(medium)
  status         SupportTicketStatus   @default(open)
  createdAt      DateTime              @default(now())
  updatedAt      DateTime              @updatedAt

  messages SupportTicketMessage[]

  @@index([status, updatedAt])
  @@index([tenantId])
  @@map("support_tickets")
}

model SupportTicketMessage {
  id         String            @id @default(cuid())
  ticketId   String
  ticket     SupportTicket     @relation(fields: [ticketId], references: [id], onDelete: Cascade)
  author     String
  authorRole SupportAuthorRole
  body       String
  createdAt  DateTime          @default(now())

  @@index([ticketId, createdAt])
  @@map("support_ticket_messages")
}

/// Append-only (no UPDATE/DELETE policy exists — docs/14 §4). Entries record the
/// language the actor saw, like the order timeline. tenantId null = platform scope.
model AuditLogEntry {
  id          String        @id @default(cuid())
  scope       AuditLogScope
  tenantId    String?
  tenant      Tenant?       @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  actor       String // display name at the time
  actorUserId String?       @db.Uuid
  action      String
  entity      String
  details     String?
  createdAt   DateTime      @default(now())

  @@index([tenantId, createdAt])
  @@index([scope, createdAt])
  @@map("audit_log_entries")
}

/// tenantId null = the platform default template; a tenant row overrides it.
model NotificationTemplate {
  id        String              @id @default(cuid())
  tenantId  String?
  tenant    Tenant?             @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  event     NotificationEvent
  channel   NotificationChannel
  message   String
  messageBn String // Bengali; costs more over SMS (70 characters per message, not 160)
  enabled   Boolean             @default(true)

  @@unique([tenantId, event, channel])
  @@map("notification_templates")
}

model NotificationDispatch {
  id        String                     @id @default(cuid())
  tenantId  String?
  tenant    Tenant?                    @relation(fields: [tenantId], references: [id], onDelete: SetNull)
  event     NotificationEvent
  channel   NotificationChannel
  recipient String
  message   String
  status    NotificationDispatchStatus
  createdAt DateTime                   @default(now())

  @@index([tenantId, createdAt])
  @@map("notification_dispatches")
}

// ═══════════════════════════════ Billing & feature flags ═══════════════════════════════

/// Seeded plans keep ids "starter" / "growth" / "scale" / "enterprise"; custom
/// plans (ros-platform-custom-plans) get cuids. Deleting a plan is soft
/// (ros-platform-deleted-plans) — invoices still point at it.
///
/// featureFlagIds: NON-EMPTY = hard whitelist; EMPTY = unrestricted (every
/// globally-enabled flag). This is the most consequential subtlety in the
/// schema — "empty means nothing" would lock every seed-plan tenant out of
/// every feature. src/lib/feature-resolution.ts is the reference implementation.
model SubscriptionPlan {
  id             String       @id @default(cuid())
  name           String
  price          Decimal      @db.Decimal(12, 2)
  billingCycle   BillingCycle @default(monthly)
  description    String
  features       String[]     @default([]) // display lines
  isPopular      Boolean      @default(false)
  maxTables      Int
  maxStaff       Int
  isCustom       Boolean      @default(false)
  featureFlagIds String[]     @default([])
  deletedAt      DateTime?

  tenants  Tenant[]
  invoices Invoice[]

  @@map("subscription_plans")
}

/// `id` keeps the catalog ids from src/lib/mock-data/feature-flags.ts ("ff-16"),
/// because FEATURE.* constants in code refer to them.
model FeatureFlag {
  id           String           @id
  label        String
  description  String
  category     String
  enabled      Boolean          @default(false)
  rolloutPhase FeatureFlagPhase

  overrides TenantFeatureOverride[]

  @@map("feature_flags")
}

/// ros-platform-tenant-feature-overrides as rows.
model TenantFeatureOverride {
  tenantId String
  tenant   Tenant      @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  flagId   String
  flag     FeatureFlag @relation(fields: [flagId], references: [id], onDelete: Cascade)
  enabled  Boolean

  @@id([tenantId, flagId])
  @@map("tenant_feature_overrides")
}

model VipOverride {
  tenantId  String          @id
  tenant    Tenant          @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  type      VipOverrideType
  value     Decimal?        @db.Decimal(12, 2)
  note      String?
  grantedAt DateTime        @default(now())

  @@map("vip_overrides")
}

/// tenantName in the TS type is a join, not a column.
model Invoice {
  id          String           @id @default(cuid())
  tenantId    String
  tenant      Tenant           @relation(fields: [tenantId], references: [id], onDelete: Restrict)
  planId      String
  plan        SubscriptionPlan @relation(fields: [planId], references: [id], onDelete: Restrict)
  amount      Decimal          @db.Decimal(12, 2)
  status      InvoiceStatus    @default(pending)
  reason      InvoiceReason?
  issuedAt    DateTime         @default(now())
  paidAt      DateTime?
  emailSentAt DateTime? // latest send; the full history is InvoiceEmail
  pdfPath     String? // `invoices` bucket

  emails InvoiceEmail[]

  @@index([tenantId, issuedAt])
  @@index([status])
  @@map("invoices")
}

/// ros-platform-invoice-email-log.
model InvoiceEmail {
  id        String   @id @default(cuid())
  invoiceId String
  invoice   Invoice  @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
  to        String
  sentAt    DateTime @default(now())

  @@index([invoiceId])
  @@map("invoice_emails")
}

🟢 বাংলা সারাংশ: §৩.১-এ নিয়মগুলো: টেবিলের নাম snake_case, কলাম camelCase; হাইফেনওয়ালা মান ("dine-in") enum @map দিয়ে, যাতে ডাটাবেজে হুবহু app-এর মানই থাকে; টাকা Decimal; সময় UTC কিন্তু রেস্টুরেন্টের নিয়ম রেস্টুরেন্টের টাইমজোনে; ডিশ মুছলে যদি কোনো প্যাকেজ বা রেসিপি সেটা ব্যবহার করে তাহলে মোছা আটকানো হয় (Restrict)। §৩.৩-এ আগের খসড়ার ভুলগুলোর তালিকা — যেমন Zone এমন একটা মডেলের দিকে ইশারা করত যেটার অস্তিত্বই নেই, প্যাকেজ ছিল না, ডিশে একাধিক ছবির জায়গা ছিল না, হাইফেনওয়ালা মান ডাটাবেজে ভিন্ন বানানে যেত। §৩.৪-এ পুরো স্কিমা, docs/backend/schema.prisma থেকে হুবহু।


4. Derived values

Never stored — each is a query, so it can never disagree with its source. (docs/16 §3.3 lists the same with the query that computes it.)

Type fieldSource
Tenant.mrrplan price after VIP override
Tenant.tablesCount, Tenant.ordersToday, Tenant.ratingcounts / averages over tables, today's orders, reviews
Branch.tablesCount, staffCount, ordersToday, revenueTodaysame, per branch
MenuItem.rating, ratingCountstored as a nightly roll-up (the one exception — read on every menu render)
CrmCustomer.totalOrders, totalSpent, lastOrderAt, loyaltyPointsorders, loyalty account
StaffMember.shiftsThisWeek, rating, assignedTablesshifts, reviews, staff_table_assignments
Rider.activeDeliveries, completedToday, earningsTodaytoday's delivery orders
Supplier.itemsSuppliedinventory items with that supplier
RestaurantTable.qrUrl/table?table=<id>&token=<qrToken>
Invoice.tenantName, SupportTicket.tenantSlug/tenantName, TableCall.tableLabel/waiterName, Reservation.tableLabeljoins

🟢 বাংলা সারাংশ: এই সংখ্যাগুলো কখনো কলাম হিসেবে রাখা হয় না — প্রতিবার কোয়েরিতে হিসাব হয়, তাই উৎসের সাথে কখনো অমিল হয় না। একমাত্র ব্যতিক্রম ডিশের রেটিং: প্রতিটা মেনু লোডে পড়া হয়, তাই রাতে একবার হিসাব করে রাখা হয়।


5. Indexes

TableIndexServes
tenantsslug uniqueevery request (proxy → page)
custom_domainsdomain unique, statusproxy lookup; the verify cron
orders(tenantId, status), (tenantId, createdAt), (tenantId, orderNumber) unique, (tenantId, clientRequestId) unique, sessionIdKDS/boards by status; analytics by date; idempotent submit; a sitting's orders
order_line_items, order_timeline_eventsorderId (+ timestamp)loading an order
menu_items(tenantId, categoryId)menu render
menu_categories(tenantId, sortOrder)menu render
meal_packages(tenantId, isActive)guest packages section
restaurant_tablesqrToken unique, (tenantId, label) unique, (tenantId, zoneId)QR scan; floor plan
table_sessions(tableId, endedAt); partial unique (tableId) where endedAt is nullresolve scan; one sitting at a time
table_calls(tenantId, resolved)waiter inbox
crm_customers(tenantId, phone) uniquecustomer by phone at checkout
branchespartial unique (tenantId) where isMainone main branch
staff_members(tenantId, email) unique, (tenantId, userId) unique, (tenantId, role)invites; can_access_tenant; role screens
audit_log_entries(tenantId, createdAt), (scope, createdAt)paged logs
invoices(tenantId, issuedAt), statusbilling history; overdue sweep
paymentsproviderRef unique, (tenantId, createdAt)IPN replay is a no-op; reports

The partial indexes live in policies.sql (Prisma can't express them).

🟢 বাংলা সারাংশ: প্রতিটা index কোন স্ক্রিন বা কাজের জন্য, টেবিলে লেখা। যেমন orders-এ (tenantId, clientRequestId) unique — একই অর্ডার দুবার জমা হওয়া আটকায়; table_sessions-এ partial unique — এক টেবিলে একসাথে একটাই বসা। Partial index Prisma লিখতে পারে না, তাই সেগুলো policies.sql-এ।


6. Row-level security and migration

Both moved to their own documents so each has room to be complete:

One rule from the old migration section survives verbatim because it is the most expensive to get wrong: SubscriptionPlan.featureFlagIds empty means unrestricted, not no features. src/lib/feature-resolution.ts is the reference implementation, and its unit tests are the spec.

🟢 বাংলা সারাংশ: RLS ও মাইগ্রেশন এখন আলাদা ডকুমেন্টে — ১৪ (Supabase ও সিকিউরিটি SQL) আর ১৬ (mock থেকে ডাটাবেজে যাওয়ার পরিকল্পনা)। একটা নিয়ম এখানেই রইল, কারণ ভুল হলে সবচেয়ে ক্ষতি: প্ল্যানের featureFlagIds খালি মানে "কোনো নিষেধাজ্ঞা নেই", "কোনো ফিচার নেই" না।