16 — Backend Cutover Plan & Readiness Checklist
Status: 📐 The plan for leaving the frontend-only phase. Nothing here has started. Read 15 for where code goes, 07 for the schema, 14 for Supabase. This doc is what order, what replaces what, and how we know each step is done.
🟢 বাংলা ভূমিকা: ফ্রন্টএন্ড-অনলি পর্ব থেকে বেরোনোর পরিকল্পনা — কোন ক্রমে কাজ হবে, আজকের কোন localStorage key বা mock ফাংশন কোন টেবিল/কোয়েরি দিয়ে বদলাবে, আর প্রতিটা ধাপ "শেষ" কখন বলা যাবে। কোড কোথায় যাবে তা ১৫-তে, স্কিমা ০৭-এ, Supabase ১৪-তে।
1. Readiness — what is already true before day one
| Prerequisite | State | Evidence |
|---|---|---|
| Every screen designed and walked in a browser | ✅ | docs/feature-lists.md re-audited against code; 226 Playwright journeys green |
| Every page reads through one async layer | ✅ | src/lib/data/{tenant,platform}.ts — pages already await |
| Every write goes through one hook per entity, with a stable interface | ✅ | src/hooks/use-* (29 hooks, §3 maps each) |
| Pure business logic separated from UI | ✅ | src/lib/order-totals.ts, allergy-filter.ts, packages.ts, package-schedule, feature-resolution.ts, table-session.ts, staff-invite.ts, branch-draft.ts, signup.ts, custom-domain.ts — all unit-tested |
| Domain types are the contract | ✅ | src/types/* — mappers must produce exactly these |
| Schema designed and machine-checked | ✅ | docs/backend/schema.prisma — prisma validate clean, 49 tables |
| Security SQL written and exercised | ✅ | docs/backend/supabase/policies.sql — isolation suite passed on Postgres 17 (docs/14 §8) |
| Every UI string translated, incl. errors-to-be | 🟡 | 1815 keys at parity; the errors.<code> keys (docs/15 §6) get added with the first action |
| Accounts, secrets, domains | ❌ | §4 — needs the owner of the business, not code |
🟢 বাংলা সারাংশ: প্রথম দিনের আগেই যা তৈরি: সব স্ক্রিন ব্রাউজারে যাচাই করা, সব পেজ একটা async লেয়ার দিয়ে পড়ে, প্রতিটা entity-র জন্য একটা hook, pure লজিক আলাদা ও টেস্ট করা, স্কিমা prisma validate পাস, সিকিউরিটি SQL Postgres 17-এ পরীক্ষিত। বাকি: অ্যাকাউন্ট, সিক্রেট আর ডোমেইন — এগুলো কোড না, ব্যবসার মালিকের কাজ।
2. Order of work
Each phase ends deployable. The site never has a "half on localStorage, half broken" state in production: a phase ships behind nothing but its own completeness, because until a hook is cut over, it keeps working exactly as today.
| # | Phase | Scope | Done when |
|---|---|---|---|
| 0 | Foundation | Add prisma, @prisma/client, @supabase/ssr, @supabase/supabase-js, zod, server-only, argon2. git mv docs/backend/schema.prisma prisma/; supabase init; copy policies.sql into supabase/migrations/. src/server/{db,auth,http} skeleton. .env.example → .env.local. CI gets a Postgres service. | prisma migrate dev + security migration apply on a clean local stack; the isolation suite (docs/14 §8) runs in CI and passes |
| 1 | Read cutover | prisma/seed.ts loads every mock array with its mock id. Each src/lib/data function body becomes a repo call inside withTenant/withPlatform. | All 226 E2E journeys pass against the seeded database with zero page changes |
| 2 | Auth | Supabase email auth for owners/managers/platform; staff invites become real (email + token); shift PIN sign-in; requireStaff/requirePlatform on every dashboard/staff route; delete SessionGuard and ros-session | Signed-out → /login, enforced on the server; a Swadh Ghar manager editing the URL to another tenant gets 404; signing-in E2E suite rewritten to real accounts |
| 3 | The core loop | sessions (scan → sitting, calls), orders.placeOrder/advanceStatus, Realtime on orders + table_calls, cart submit → action with clientRequestId | Guest on a phone orders; kitchen tablet sees it within 1 s without reload; waiter call arrives; retrying a submit yields one order; an old QR print is refused |
| 4 | Owner writes | menu, packages, floor/QR, staff, branches, promos, tenant branding/domain, uploads to Storage | Every dashboard board persists across devices; ros-* keys for those entities no longer read (grep proves it) |
| 5 | Money | payments (cash/card-terminal first, then bKash/Nagad/Stripe webhooks — docs/08), refunds, loyalty earn/redeem, receipts from DB | A paid order's Payment row exists; a replayed IPN changes nothing; refund produces its own row |
| 6 | Platform | plans, invoices (+ PDF to invoices bucket, email), VIP, feature overrides, support, audit, platform users | Every platform-admin board persists; tenant sees its own invoices only |
| 7 | Operations | inventory + BOM decrement on completion, reservations, CRM segments, notifications dispatch, analytics as SQL aggregates, cron jobs | Stock drops when an order completes; nightly jobs idempotent |
| 8 | Custom domains live | Vercel Domains API on requestDomain, verify-domains cron, proxy lookup with cache | A real customer domain serves its menu with TLS; the pending state is visible to the owner until then (docs/13 §6) |
Phases 4, 6 and 7 can run in parallel between people; 0 → 1 → 2 → 3 cannot — each is the ground the next stands on.
🟢 বাংলা সারাংশ: প্রতিটা পর্ব শেষে সাইট deploy করার মতো অবস্থায় থাকে — যে hook এখনো কাটওভার হয়নি সেটা আজকের মতোই কাজ করে। ক্রম: (০) ভিত্তি — ডিপেনডেন্সি, স্কিমা, সিকিউরিটি মাইগ্রেশন; (১) পড়া — সিড করা ডাটাবেজের বিরুদ্ধে সব ২০৭ E2E পাস, কোনো পেজ না বদলে; (২) অথ — সার্ভারে যাচাই, SessionGuard মুছে ফেলা; (৩) মূল লুপ — স্ক্যান, অর্ডার, রিয়েলটাইম; (৪) মালিকের write; (৫) টাকা; (৬) প্ল্যাটফর্ম; (৭) অপারেশন; (৮) কাস্টম ডোমেইন। ০→১→২→৩ ধারাবাহিক; ৪, ৬, ৭ সমান্তরালে চলতে পারে।
3. What replaces what
3.1 localStorage keys → tables
| Key (today) | Written by | Becomes | Module |
|---|---|---|---|
ros-live-orders:{t} | cart submit, boards | orders, order_line_items, order_timeline_events | orders |
ros-my-order-ids:{t} | cart | stays (docs/15 §7) — server checks access | — |
ros-table-session:{t}:{table} | use-table-session | table_sessions | sessions |
ros-cart:{t}:{table}:{session} | cart | stays until "Place order" | — |
ros-guest-name:{t}:{table} | name prompt | stays; snapshotted into lines/orders | — |
ros-table-calls:{t} | guest buttons, waiter | table_calls | sessions |
ros-table-tokens:{t} | QR regenerate | restaurant_tables.qrToken (+ qrTokenRotatedAt) | floor |
ros-floor-plan:{t} | tables floor | zones rows; restaurant_tables.floorX/Y/zoneId | floor |
ros-qr-printed:{t} | onboarding | tenants.qrPrintedAt | tenants |
ros-menu-images:{t} | menu manager | menu_items.images + menu-images bucket | menu |
ros-menu-allergens:{t} | menu manager | menu_items.allergens | menu |
ros-menu-names:{t} | menu manager | menu_items.name + a nameBn column | menu |
ros-menu-edits:{t} | menu manager | menu_items updates (price, description, descriptionBn, category, spice, diet tags, available), inserts for added, soft deletes (deletedAt) for removed; menu_categories inserts, and updates of name/nameBn from categoryPatches | menu |
ros-menu-sold-out:{t} | KDS | menu_items.available (a shift-scoped flag, cleared nightly) | menu |
ros-reviews:{t} | guest orders panel | reviews | crm |
ros-packages:{t} | packages board | meal_packages (+ items, choices); hiddenIds → isActive=false | packages |
ros-package-images:{t} | packages board | meal_packages.images + package-images bucket | packages |
ros-branding-text:{t} | settings | tenants.name, cuisine, cuisineBn, tagline, taglineBn, logoToken | tenants |
ros-branding-images:{t} | settings | tenants.logo, tenants.coverImage + branding bucket | tenants |
ros-layout-width:{t} | settings | tenants.layoutWidth | tenants |
ros-accent:{t} | settings | tenants.accent (still echoed to localStorage for the pre-paint script, Rule 4) | tenants |
ros-accent:platform, ros-theme, ros-glass-intensity | appearance | stay — viewer preference | — |
ros-sound:{kitchen|waiter} | shift screens | stays — per device | — |
ros-custom-domain:{t} | settings | custom_domains | tenants |
ros-promos:{t} | promos board | promos (extra → rows, overrides → updates) | promos |
ros-promo-design:{t} | promo CMS | promo_landing_designs.design | tenants |
ros-branches:{t} | branches board | branches | branches |
ros-active-branch:{t} | switcher | stays — per device | — |
ros-staff-invites:{t} | staff board | staff_invites | staff |
ros-loyalty:{t}:{phone} | checkout | loyalty_accounts, loyalty_transactions | crm |
ros-onboarding-dismissed:{t} | checklist | stays — per viewer | — |
ros-session | login screens | deleted — Supabase Auth cookies | staff/auth |
ros-platform-tenant-feature-overrides | feature flags | tenant_feature_overrides | features |
ros-platform-custom-plans / -plan-edits / -deleted-plans | plan builder | subscription_plans (isCustom, deletedAt) | billing |
ros-platform-plan-overrides | tenant detail | tenants.planId (+ audit entry) | billing |
ros-platform-invoice-overrides / -generated-invoices | subscriptions | invoices | billing |
ros-platform-invoice-email-log | subscriptions | invoice_emails (+ invoices.emailSentAt) | billing |
ros-platform-vip-overrides | tenant detail | vip_overrides | billing |
ros-platform-support-tickets | support inbox | support_tickets, support_ticket_messages | support |
ros-platform-audit-log | everywhere | audit_log_entries | audit |
ros-roadmap-watchlist | roadmap | stays — per viewer | — |
Verification for §3.1: after phase 4/6, grep -rn '"ros-' src/hooks must list only the keys marked stays. That grep is the done-check, not a feeling.
3.2 src/lib/data → queries
| Function | Query (inside withTenant(tenantId) unless noted) |
|---|---|
getTenant(slug) | tenants by slug with customDomain; public read — called before any tenant scope exists, so it runs in withPlatform and returns only public fields |
getAllTenantSlugs() | withPlatform: select slug where status in (active, trial) — only for generateStaticParams |
getBranches | branches + count(tables), count(orders today), sum(total today) in Tenant.timezone |
getMenu | categories (sorted) + items with variants/addons — cached, tag tenant:<id>:menu |
getPackages | packages + items + choice groups/options — cached, tag tenant:<id>:menu |
getOrders / getOrder | orders + items + timeline, newest first; dashboards page by createdAt |
getTables / getTable | restaurant_tables + zone; qrUrl built by mapper |
getStaff / getRiders | staff_members where not deactivated; rider stats are aggregates over today's orders |
getOwnerEmail | first staff_members with role owner |
getCrmCustomers | customers + count/sum/max(orders) + loyalty points |
getReviews, getReservations, getInventory, getSuppliers, getStockMovements, getPurchaseOrders, getDeliveryZones, getPromos, getPromo | straight table reads, tenant-scoped |
getSalesTrend, getTopItems | aggregates over orders/order_line_items for the last 7 days in Tenant.timezone — candidates for a materialised view (docs/09) |
getAuditLog | audit_log_entries for tenant, newest first, paged |
getAllTenants, getPlatformStats, getRevenueTrend, getInvoices, getPlatformUsers, getPlatformAuditLog, getAllTenantAuditLogs, getSupportTickets, getNotificationTemplates, getNotificationDispatchLog | withPlatform reads; MRR = sum over active tenants of plan price after VIP override |
The components listed at the bottom of src/lib/data/tenant.ts that still import mock-data directly are phase-1 work too: each gets its data as a prop from its page, and cart-sheet.tsx's reads move into placeOrder (it currently decides the next order number and promo validity in the browser — exactly what §15/5 forbids).
3.3 Derived values — never stored
| UI field | Computed from |
|---|---|
Tenant.mrr | plan price, VIP override |
Tenant.tablesCount, Branch.tablesCount | count(restaurant_tables) |
Tenant.ordersToday, Branch.ordersToday/revenueToday | orders since local midnight |
Tenant.rating, MenuItem.rating/ratingCount | avg/count(reviews) (item rating rolled up nightly) |
Branch.staffCount | staff assigned to the branch's tables |
CrmCustomer.totalOrders/totalSpent/lastOrderAt/loyaltyPoints | orders + loyalty account |
StaffMember.shiftsThisWeek, rating; Rider.activeDeliveries/completedToday/earningsToday | orders / shifts in the week |
Supplier.itemsSupplied | inventory items with that supplier |
RestaurantTable.qrUrl | /table?table=<id>&token=<qrToken> |
Invoice.tenantName, SupportTicket.tenantName/tenantSlug, TableCall.tableLabel/waiterName, Reservation.tableLabel | joins |
🟢 বাংলা সারাংশ: §৩.১-এ আজকের প্রতিটা localStorage key-র পাশে লেখা আছে সেটা কোন টেবিলে যাবে, নাকি ইচ্ছাকৃতভাবে ব্রাউজারেই থাকবে। কাজ শেষের প্রমাণ একটা grep: src/hooks-এ শুধু "stays" চিহ্নিত key-গুলো থাকবে। §৩.২-এ src/lib/data-র প্রতিটা ফাংশন কোন কোয়েরি হবে, আর §৩.৩-এ UI-র যেসব সংখ্যা কখনো কলাম হিসেবে সংরক্ষিত হবে না (MRR, আজকের অর্ডার, রেটিং ইত্যাদি — সব কোয়েরিতে হিসাব হয়)।
4. Decisions and accounts the business must supply
These block phases, and none of them is a coding question.
| Needed | For phase | Notes |
|---|---|---|
Supabase organisation + 2 projects (ros-staging, ros-prod), region ap-south-1 (Mumbai) or ap-southeast-1 (Singapore) | 0 | Closest regions to Dhaka; pick one and keep DB + Vercel functions in it (docs/14 §1) |
Vercel team, production domain restaurant-os.com + wildcard *.restaurant-os.com | 0, 8 | Wildcard needs Vercel nameservers (docs/13 §5) |
| Transactional email sender (Resend/Postmark) + verified domain (SPF, DKIM) | 2 | Invites, password reset, invoices |
| SMS gateway (SSL Wireless / BulkSMS BD / Twilio) + approved sender id | 7 | Order updates, OTP if ever used |
| bKash & Nagad merchant accounts (sandbox first), Stripe account | 5 | Merchant onboarding takes weeks — start early |
| Upstash Redis | 3 | Rate limiting PIN sign-in, order submit (docs/12 §3) |
Real legal text replacing the templates at /legal/* | before launch | The pages say they are templates |
| Data retention decision: how long to keep guest phone numbers | 3 | Needed for the privacy policy and a purge cron |
🟢 বাংলা সারাংশ: কোড না, ব্যবসার সিদ্ধান্ত ও অ্যাকাউন্ট — এগুলো ছাড়া পর্বগুলো আটকে থাকবে: Supabase প্রজেক্ট (মুম্বাই বা সিঙ্গাপুর রিজিয়ন), Vercel ও wildcard ডোমেইন, ইমেইল সেন্ডার, SMS গেটওয়ে, bKash/Nagad/Stripe মার্চেন্ট অ্যাকাউন্ট (সময় লাগে — আগে শুরু করুন), Upstash, আসল আইনি টেক্সট, আর অতিথির ফোন নম্বর কতদিন রাখা হবে সেই সিদ্ধান্ত।
5. Seeding
prisma/seed.ts is the bridge that makes phase 1 provable: the database starts with exactly the data the E2E suite already asserts against.
- Import the mock arrays from
src/lib/mock-data/*(they are plain TS — the seed reuses them rather than copying). - Keep every mock id (
tn_swadh_ghar,sg-…,ff-16, plan idsstarter…). Tests, deep links andFEATURE.*constants refer to them. - Generate what the mocks don't have:
zonesfrom the distinct zone ids (the five seeded ones getlabelKey),qrTokenfrom the mock table, oneorder_countersrow per tenant starting above the highest mock number,staff_members.pinHashfromdemoPin(staffId)so the staff-login screen keeps working in staging. - Run in one transaction per tenant under
withPlatform;upserteverywhere so the seed is re-runnable. - Never run against production: the script refuses unless
DATABASE_URL's host matches the local or staging project.
🟢 বাংলা সারাংশ: সিড স্ক্রিপ্ট mock-data থেকে হুবহু একই ডেটা ডাটাবেজে বসায়, mock id অপরিবর্তিত রেখে — যাতে বর্তমান E2E টেস্টগুলো ডাটাবেজের বিরুদ্ধেও চলে। mock-এ যা নেই (zone, order counter, PIN hash) তা তৈরি করে। বারবার চালানো যায় (upsert), আর প্রোডাকশনে চলতে অস্বীকার করে।
6. Testing the backend
| Layer | Tool | What it proves |
|---|---|---|
| Pure logic | Vitest (exists, 322 tests) | Unchanged — the server reuses these functions |
| Mappers | Vitest | Every enum value round-trips; Decimal → number; derived fields |
| Services | Vitest + PGlite (in-process Postgres 17, the same harness used to verify policies.sql) | placeOrder rules, idempotency, schedule windows, plan limits — fast, no Docker |
| Security | Vitest + PGlite, running policies.sql | The docs/14 §8 isolation assertions, on every CI run |
| Integration | Supabase local stack (supabase start) in CI | Realtime delivery, Storage signed URLs, auth cookies |
| Journeys | Playwright (exists, 207) | Same specs against the seeded DB; the signing-in spec rewritten for real auth |
New E2E journeys the backend makes possible — none can be written today because they need two devices sharing state:
- Guest phone places an order → kitchen tablet shows it, no reload.
- Owner changes a price on the laptop → the next scan on a phone shows it.
- Owner rotates a table's QR → the old URL is refused, the new one works.
- Two guests submit at the same instant → consecutive order numbers.
- Manager of tenant A cannot load any tenant B URL (404, not 403 — existence is not leaked).
🟢 বাংলা সারাংশ: বিদ্যমান ৩২২টা ইউনিট টেস্ট আর ২০৭টা E2E অপরিবর্তিত থাকবে। নতুন: mapper টেস্ট, PGlite দিয়ে service ও সিকিউরিটি টেস্ট (Docker ছাড়াই দ্রুত), আর Supabase লোকাল স্ট্যাকে ইন্টিগ্রেশন টেস্ট। ব্যাকএন্ড আসার পরেই যেসব journey লেখা সম্ভব: অতিথির অর্ডার রিলোড ছাড়া কিচেনে দেখা, দাম বদলালে পরের স্ক্যানে দেখা, পুরনো QR প্রত্যাখ্যাত হওয়া, একসাথে দুটো অর্ডারে পরপর নম্বর, আর অন্য টেন্যান্টের URL-এ 404।
7. Deliberately later
Designed-for but not in phases 0–8, so nobody builds them by accident: shared live table carts (one cart across several phones — needs a carts table and presence), offline-first POS with sync (docs/roadmap Phase 25), delivery aggregator ingest, multi-currency per tenant, AI insights. The schema leaves room for each without a breaking migration.
🟢 বাংলা সারাংশ: যা ইচ্ছাকৃতভাবে পরে: একাধিক ফোনে একটাই লাইভ টেবিল কার্ট, অফলাইন-ফার্স্ট POS, ডেলিভারি অ্যাগ্রিগেটর, টেন্যান্ট প্রতি একাধিক মুদ্রা, AI ইনসাইট। স্কিমায় এগুলোর জায়গা আছে, ভাঙা মাইগ্রেশন ছাড়াই যোগ করা যাবে।