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
- 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 carriestenantId; child rows (order lines, variants, messages) reach it through their parent, and so do their policies. - The domain types are the contract. Each model maps to a type in
src/types/*. Where a column differs from the type (flattenedbranding, a join instead oftenantName), the model's comment says why — and a per-module mapper is the only code that knows (docs/15 §6). - 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 andFEATURE.*constants keep pointing at the same rows. - 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.
- 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.
- 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. - JSON only when a value is read and written whole —
addonson an order line,comboItemson a promo, the promo landingdesign. 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
2.2 Menu, packages, inventory
2.3 People, customers, marketing
2.4 Platform — plans, billing, flags, support
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
| Convention | Why |
|---|---|
Tables snake_case plural via @@map; columns keep Prisma's camelCase | Readable 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 references | Deleting 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 direct | Supavisor 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 draft | Now | Why it mattered |
|---|---|---|
Zone related to a model DiningTable that didn't exist; tables still used a TableZone enum | Zone rows ← RestaurantTable.zoneId | Didn'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 packages | MealPackage + items + choice groups/options + schedule | Built, shipped, behind FEATURE.packages |
Order had no branchId, tableLabel, sessionId, clientRequestId; lines had no packageContents | added | Multi-branch, receipts, sittings, offline retry, kitchen plating |
Timeline had only label | labelKey + label | Seeded 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-relation | fixed | Didn't validate |
No TableSession, StaffInvite, CustomDomain, PlatformUser, Payment, OrderCounter, notifications, promo design, invoice emails | added | Each 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 subscriptionPlanId | planId → SubscriptionPlan | Custom plans have generated ids; two sources of truth disagreed |
Invoice.pdfUrl | pdfPath (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
3.4.2 Menu, packages, inventory
3.4.3 People, customers, marketing
3.4.4 Platform — plans, billing, flags, support
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 field | Source |
|---|---|
Tenant.mrr | plan price after VIP override |
Tenant.tablesCount, Tenant.ordersToday, Tenant.rating | counts / averages over tables, today's orders, reviews |
Branch.tablesCount, staffCount, ordersToday, revenueToday | same, per branch |
MenuItem.rating, ratingCount | stored as a nightly roll-up (the one exception — read on every menu render) |
CrmCustomer.totalOrders, totalSpent, lastOrderAt, loyaltyPoints | orders, loyalty account |
StaffMember.shiftsThisWeek, rating, assignedTables | shifts, reviews, staff_table_assignments |
Rider.activeDeliveries, completedToday, earningsToday | today's delivery orders |
Supplier.itemsSupplied | inventory items with that supplier |
RestaurantTable.qrUrl | /table?table=<id>&token=<qrToken> |
Invoice.tenantName, SupportTicket.tenantSlug/tenantName, TableCall.tableLabel/waiterName, Reservation.tableLabel | joins |
🟢 বাংলা সারাংশ: এই সংখ্যাগুলো কখনো কলাম হিসেবে রাখা হয় না — প্রতিবার কোয়েরিতে হিসাব হয়, তাই উৎসের সাথে কখনো অমিল হয় না। একমাত্র ব্যতিক্রম ডিশের রেটিং: প্রতিটা মেনু লোডে পড়া হয়, তাই রাতে একবার হিসাব করে রাখা হয়।
5. Indexes
| Table | Index | Serves |
|---|---|---|
tenants | slug unique | every request (proxy → page) |
custom_domains | domain unique, status | proxy lookup; the verify cron |
orders | (tenantId, status), (tenantId, createdAt), (tenantId, orderNumber) unique, (tenantId, clientRequestId) unique, sessionId | KDS/boards by status; analytics by date; idempotent submit; a sitting's orders |
order_line_items, order_timeline_events | orderId (+ timestamp) | loading an order |
menu_items | (tenantId, categoryId) | menu render |
menu_categories | (tenantId, sortOrder) | menu render |
meal_packages | (tenantId, isActive) | guest packages section |
restaurant_tables | qrToken unique, (tenantId, label) unique, (tenantId, zoneId) | QR scan; floor plan |
table_sessions | (tableId, endedAt); partial unique (tableId) where endedAt is null | resolve scan; one sitting at a time |
table_calls | (tenantId, resolved) | waiter inbox |
crm_customers | (tenantId, phone) unique | customer by phone at checkout |
branches | partial unique (tenantId) where isMain | one 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), status | billing history; overdue sweep |
payments | providerRef 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:
- RLS, roles, grants, Storage and Realtime policies — 14-supabase-setup.md §4, with the exact SQL in
docs/backend/supabase/policies.sqland the assertions it passed in §8. - Moving from mock data — 16-backend-cutover-plan.md: every
localStoragekey andsrc/lib/datafunction mapped to its table and query, the seed, and the phase order.
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 খালি মানে "কোনো নিষেধাজ্ঞা নেই", "কোনো ফিচার নেই" না।