15 — Backend Architecture, Folder Structure & Modules
Status: 📐 Target design — nothing in this document exists in src/ yet. It is the shape the backend is built into, written before the first line of it so the first line lands in the right place. Companion docs: 07 (schema), 14 (Supabase), 16 (the order of work).
🟢 বাংলা ভূমিকা: ব্যাকএন্ডের কোড কোথায় থাকবে, কোন মডিউল কী করবে, কোন লেয়ার কাকে ডাকতে পারবে — সব এখানে। এখনো কিছুই বানানো হয়নি; প্রথম লাইন লেখার আগেই কাঠামোটা ঠিক করে রাখা হলো, যাতে প্রথম লাইনটাই সঠিক জায়গায় বসে।
1. The shape, in one picture
Three rules make the picture work:
- Components never change. A hook keeps its returned interface; only its insides swap from
useLocalStorageto a server action plus a Realtime subscription. This was the contract every hook was written to (docs/02 §4). - Reads and writes take different roads. Reads: server component →
src/lib/data→ module repo. Writes: client → server action → module service → repo. There is no REST API for the app's own UI. - The server never trusts a number the browser computed. Prices, totals, order numbers, promo validity and package schedules are recomputed by the same pure functions in
src/lib/*that the UI already uses — one implementation, run twice.
🟢 বাংলা সারাংশ: তিনটা নিয়ম: (১) কম্পোনেন্ট বদলাবে না — হুকের ভেতরটা শুধু localStorage থেকে server action-এ যাবে; (২) পড়া আর লেখা আলাদা পথে — পড়া src/lib/data দিয়ে, লেখা server action দিয়ে; নিজের UI-এর জন্য কোনো REST API নেই; (৩) ব্রাউজারের হিসাব করা দাম/টোটাল/অর্ডার নম্বর সার্ভার কখনো বিশ্বাস করবে না — src/lib/*-এর একই pure ফাংশন দিয়ে আবার হিসাব করবে।
2. Folder structure (target)
New folders are marked +. Everything unmarked exists today and keeps its job.
restaurant-os-saas-v2/
├── prisma/ + moved from docs/backend/ on day one
│ ├── schema.prisma the file docs/07 embeds
│ ├── migrations/ prisma migrate dev output — committed
│ └── seed.ts mock-data → rows, keeping mock ids (docs/16 §5)
├── supabase/ + `supabase init`
│ ├── config.toml local stack: auth, storage, realtime
│ └── migrations/
│ └── <ts>_security.sql docs/backend/supabase/policies.sql
├── src/
│ ├── proxy.ts host → tenant; gains the custom-domain lookup (docs/14 §7)
│ ├── app/
│ │ ├── [locale]/… unchanged — pages stay server components
│ │ └── api/ + ONLY for callers that are not our UI
│ │ ├── webhooks/bkash/route.ts payment IPN (docs/08)
│ │ ├── webhooks/stripe/route.ts
│ │ ├── webhooks/sms-status/route.ts
│ │ └── cron/
│ │ ├── expire-sessions/route.ts ends idle table sittings
│ │ ├── verify-domains/route.ts re-checks pending custom domains
│ │ └── invoices/route.ts monthly renewal invoices
│ ├── lib/
│ │ ├── data/ reads — bodies swap from mock to repo calls
│ │ ├── supabase/ + browser.ts — the ONE browser client (Realtime + Storage)
│ │ └── *.ts pure logic, shared by UI and server (unchanged)
│ ├── hooks/ same exports; internals call actions + Realtime
│ ├── types/ the domain types — mappers produce exactly these
│ └── server/ + everything that must never reach a bundle
│ ├── db/
│ │ ├── client.ts Prisma singleton (dev hot-reload safe)
│ │ └── scoped.ts withTenant() / withPlatform() — the RLS wrapper (§4)
│ ├── auth/
│ │ ├── supabase-server.ts createServerClient with Next cookies
│ │ ├── actor.ts getActor(): who is calling, from session or PIN cookie
│ │ ├── guards.ts requireStaff(roles), requirePlatform(), requireTableToken()
│ │ └── pin.ts argon2 verify + rate limit for shift PINs
│ ├── http/
│ │ ├── errors.ts AppError + codes (§6)
│ │ ├── action.ts defineAction(): zod parse → guard → run → ActionResult
│ │ └── rate-limit.ts Upstash limiter (docs/12 §3)
│ ├── integrations/ bkash.ts, nagad.ts, stripe.ts, sms.ts, vercel-domains.ts, email.ts
│ └── modules/ one folder per bounded context (§3)
│ └── orders/
│ ├── index.ts the module's public surface — import only this
│ ├── orders.schema.ts zod: action inputs
│ ├── orders.repo.ts Prisma queries; takes a scoped tx, never the global client
│ ├── orders.mapper.ts row → src/types Order (enums, Decimal, dates)
│ ├── orders.service.ts rules: place, advance, cancel, mark-paid
│ ├── orders.actions.ts 'use server' — thin: defineAction(schema, guard, service.fn)
│ ├── orders.events.ts realtime/notification side effects after commit
│ └── orders.test.ts service tests against PGlite (docs/16 §6)
└── docs/backend/ schema + SQL until day one, then emptied into prisma/ and supabase/
Why src/server/ and not src/lib/server/: a top-level folder is one ESLint rule — no-restricted-imports forbids any file under src/components or src/hooks from importing @/server/* — and every file in it starts with import "server-only", so a mistaken import fails the build instead of shipping the Prisma client (and DATABASE_URL) to a browser.
🟢 বাংলা সারাংশ: নতুন ফোল্ডার: prisma/ (স্কিমা, মাইগ্রেশন, সিড), supabase/ (লোকাল স্ট্যাক ও সিকিউরিটি SQL), src/server/ (ডাটাবেজ, অথ, মডিউল — যা কখনো ব্রাউজারে যাবে না), আর src/app/api/ শুধু বাইরের কলারদের জন্য (পেমেন্ট webhook, cron)। src/server/ আলাদা টপ-লেভেল ফোল্ডার যাতে একটা ESLint নিয়মেই কম্পোনেন্ট থেকে সেটা import নিষিদ্ধ করা যায়, আর প্রতিটা ফাইলে import "server-only" থাকায় ভুল import হলে বিল্ডই ফেইল করবে।
3. Modules
One module per bounded context. The list is not invented — it is the dashboard sidebar, the staff apps and the platform admin, each already a UI with its own hook(s).
| Module | Owns (tables) | Replaces (today) | Main actions |
|---|---|---|---|
tenants | tenants, custom_domains, promo_landing_designs | ros-branding-images, ros-layout-width, ros-accent (tenant scope), ros-custom-domain, ros-qr-printed, ros-promo-design | updateBranding, uploadBrandImage, setLayoutWidth, requestDomain, verifyDomain, markQrPrinted |
menu | menu_categories, menu_items, variants, addons, ingredients | ros-menu-images, ros-menu-allergens | upsertCategory, upsertItem, setAvailability, setAllergens, addPhotos, reorder |
packages | meal_packages (+ items, choice groups/options) | ros-packages, ros-package-images | createPackage, updatePackage, toggleActive, setSchedule |
floor | zones, restaurant_tables, staff_table_assignments | ros-floor-plan, ros-table-tokens | addZone, renameZone, hideZone, addTable, moveTable, rotateQr |
sessions | table_sessions, table_calls | ros-table-session, ros-table-calls | resolveScan (start/resume), callWaiter, requestBill, resolveCall, clearTable |
orders | orders, order_line_items, order_timeline_events, order_counters | ros-live-orders, ros-my-order-ids | placeOrder, advanceStatus, cancel, markItemServed, assignRider, markPaid |
payments | payments | — (cash only today) | startPayment, handleIpn, refund |
staff | staff_members, staff_invites | ros-staff-invites, ros-session (staff part) | invite, revokeInvite, acceptInvite, setPin, deactivate, pinSignIn |
branches | branches | ros-branches | addBranch, updateBranch, removeBranch |
promos | promos | ros-promos | createPromo, updatePromo, toggle, validateCode |
crm | crm_customers, loyalty_accounts, loyalty_transactions, reviews | ros-loyalty | upsertCustomerFromOrder, earn, redeem, bonus, submitReview |
reservations | reservations | (seed only) | create, confirm, seat, cancel, noShow |
inventory | inventory_items, stock_movements, suppliers, purchase_orders(+lines) | (seed only) | adjustStock, createPurchaseOrder, receive |
delivery | delivery_zones | (seed only) | upsertZone, quote |
analytics | — (read-only aggregates over orders) | computed from mock | salesTrend, topItems, channelBreakdown, costing, insights |
billing | subscription_plans, invoices, invoice_emails, vip_overrides | ros-platform-custom-plans / -plan-edits / -deleted-plans / -plan-overrides / -invoice-overrides / -vip-overrides / -generated-invoices / -invoice-email-log | createPlan, editPlan, deletePlan, changeTenantPlan, issueInvoice, markPaid, grantVip, emailInvoice |
features | feature_flags, tenant_feature_overrides | ros-platform-tenant-feature-overrides | toggleGlobal, setTenantOverride — resolution stays src/lib/feature-resolution.ts |
audit | audit_log_entries | ros-platform-audit-log | record() — called by other services, never by the UI |
support | support_tickets, support_ticket_messages | ros-platform-support-tickets | openTicket, reply, setStatus |
notifications | notification_templates, notification_dispatches | (seed only) | upsertTemplate, dispatch (from orders.events) |
platform-users | platform_users | (seed only) | invite, setRole, suspend |
Dependency rules
- App → module: pages and hooks import
@/server/modules/<m>(itsindex.ts) and nothing deeper. - Module → module: only through the other module's
index.ts, and only downward:ordersmay callmenu,packages,promos,crm,audit,notifications; nothing callsordersexceptpaymentsandsessions. A cycle means a service is in the wrong module. - Module →
src/lib: freely. The pure functions are the point. src/lib→ module: never.src/libmust stay importable by client components.- Repo → Prisma: a repo function takes the scoped transaction as its first argument (
repo.list(tx, …)). It cannot reach the global client, so it cannot run outside a tenant scope.
🟢 বাংলা সারাংশ: প্রতিটা মডিউল একটা আলাদা ব্যবসায়িক এলাকা — ড্যাশবোর্ডের সাইডবার, স্টাফ অ্যাপ আর প্ল্যাটফর্ম অ্যাডমিন থেকেই তালিকাটা এসেছে। টেবিলে দেখানো আছে কোন মডিউল কোন টেবিলের মালিক আর আজকের কোন localStorage key-কে প্রতিস্থাপন করবে। নির্ভরতার নিয়ম: পেজ শুধু মডিউলের index.ts import করবে; এক মডিউল অন্যটাকে শুধু index.ts দিয়ে এবং শুধু নিচের দিকে ডাকবে (চক্র মানে ভুল জায়গায় কোড); src/lib কখনো মডিউল import করবে না; আর repo ফাংশন সবসময় tenant-scoped transaction প্রথম আর্গুমেন্টে নেয়, তাই tenant scope-এর বাইরে চলতেই পারে না।
4. Tenancy in the code path
Every server entry point runs inside exactly one of two wrappers:
// src/server/db/scoped.ts
import "server-only";
import { prisma } from "./client";
import type { Prisma } from "@prisma/client";
export type Tx = Prisma.TransactionClient;
/** Everything a tenant-scoped request does, in one transaction whose RLS
* context is that tenant. SET LOCAL dies with the transaction, so a pooled
* connection can never carry one request's tenant into the next. */
export function withTenant<T>(tenantId: string, fn: (tx: Tx) => Promise<T>) {
return prisma.$transaction(async (tx) => {
await tx.$executeRaw`select set_config('app.tenant_id', ${tenantId}, true)`;
return fn(tx);
});
}
/** Platform admin. Only callable after requirePlatform() has verified the
* caller against platform_users — the wrapper itself trusts its caller. */
export function withPlatform<T>(fn: (tx: Tx) => Promise<T>) {
return prisma.$transaction(async (tx) => {
await tx.$executeRaw`select set_config('app.platform', 'on', true)`;
return fn(tx);
});
}
Where the tenant id comes from:
| Caller | Tenant from | Guard |
|---|---|---|
Guest on /table, /promo, /tables | Host header → proxy.ts → [tenant] param → getTenant(slug) | requireTableToken(tableId, qrToken) for anything that writes |
| Dashboard, KDS, waiter, rider | same route param | requireStaff(tenantId, roles) — session user must have a live staff_members row there with one of roles |
| Platform admin | none — cross-tenant | requirePlatform() → withPlatform |
| Webhook | looked up from the payment/provider reference, never from the body's claim | signature verification first |
| Cron | iterates tenants | CRON_SECRET header |
The route param is not trusted by itself. requireStaff checks the session user against the tenant the URL names; a manager of Swadh Ghar who edits the URL to /tenants/sakura-sushi/dashboard gets a 404, and even a guard bug would leave RLS returning zero rows.
🟢 বাংলা সারাংশ: প্রতিটা সার্ভার অনুরোধ দুটো wrapper-এর একটার ভেতরে চলে: withTenant (একটা transaction, যেখানে SET LOCAL app.tenant_id সেট করা — transaction শেষ হলে সেটাও মুছে যায়, তাই pooled connection-এ এক অনুরোধের tenant পরেরটায় যায় না) অথবা withPlatform (প্ল্যাটফর্ম অ্যাডমিন, যাচাইয়ের পরে)। টেবিলে দেখানো আছে কোন কলারের tenant কোথা থেকে আসে আর কোন guard লাগে। URL-এর tenant একা বিশ্বাস করা হয় না — সেশনের ইউজার সেই রেস্টুরেন্টের স্টাফ কিনা যাচাই হয়; guard-এ বাগ থাকলেও RLS শূন্য সারি ফেরত দেবে।
5. Anatomy of one action — placeOrder
The most important write in the product, and the template for the rest.
// src/server/modules/orders/orders.schema.ts
export const placeOrderInput = z.object({
tenantSlug: z.string(),
channel: z.enum(["dine-in", "takeaway", "delivery"]),
table: z.object({ id: z.string(), token: z.string(), sessionId: z.string() }).optional(),
lines: z.array(z.object({
menuItemId: z.string().optional(), // a dish…
packageId: z.string().optional(), // …or a package
variantId: z.string().optional(),
addonIds: z.array(z.string()).default([]),
choices: z.record(z.string(), z.array(z.string())).default({}), // groupId → picked menuItemIds
quantity: z.number().int().min(1).max(50),
notes: z.string().max(200).optional(),
addedBy: z.string().max(40).optional(),
})).min(1).max(100),
promoCode: z.string().max(40).optional(),
customer: z.object({ name: z.string().max(80), phone: z.string() }).partial().optional(),
delivery: z.object({ zoneId: z.string(), address: z.string().max(300) }).optional(),
clientRequestId: z.string().uuid(), // idempotency — see below
});
What the service does, in order, inside one withTenant transaction:
- Idempotency. If an order with this
(tenantId, clientRequestId)exists, return it. The offline service worker retries queued submits and phones double-tap; both must produce one order. - Gate. Tenant is
active/trial;resolveFeatureallows the channel (delivery, packages); for dine-in,requireTableToken— the token must be the table's currentqrToken(an old print fails) and the session must be the table's open one. - Price from the database. Load the referenced menu items, variants, addons and packages. Unavailable items, a package outside its schedule (
isPackageAvailableNow, evaluated inTenant.timezone), a choice group with the wrong number of picks →AppError("ITEM_UNAVAILABLE" | "PACKAGE_CLOSED" | "INVALID_CHOICE"). - Totals via
src/lib/order-totals.ts— the same function the cart used — plus the promo (promos.validateCode) and delivery zone fee/minimum. - Number via
select app.next_order_number($tenantId). - Write order + line snapshots (name, tile, first photo,
packageContents) + first timeline event. - CRM —
crm.upsertCustomerFromOrderwhen a phone was given; loyalty earn happens on completion, not placement. - Commit, then events. After commit only: Realtime has already seen the INSERT (staff boards update);
notifications.dispatch("order-placed");audit.record.
It returns { ok: true, data: { orderId, orderNumber, total } } — the guest's tracking screen reads by orderId, which is unguessable (cuid), and is additionally bound to the table session.
🟢 বাংলা সারাংশ: placeOrder হলো সবচেয়ে গুরুত্বপূর্ণ write, আর বাকি সবগুলোর নমুনা। একটা transaction-এ ধাপগুলো: (১) clientRequestId দিয়ে একই অর্ডার দুবার হওয়া আটকানো (অফলাইন retry, ডাবল ট্যাপ); (২) টেন্যান্ট সক্রিয় কিনা, ফিচার চালু কিনা, QR টোকেন টেবিলের বর্তমান টোকেন কিনা যাচাই; (৩) দাম ডাটাবেজ থেকে — ব্রাউজার থেকে না; প্যাকেজের সময়সূচি রেস্টুরেন্টের টাইমজোনে; (৪) কার্টের একই order-totals ফাংশন দিয়ে টোটাল; (৫) next_order_number দিয়ে নম্বর; (৬) লাইন আইটেমের snapshot লেখা; (৭) ফোন নম্বর থাকলে CRM; (৮) commit-এর পরে নোটিফিকেশন ও অডিট।
6. Conventions every module follows
Result shape. Actions never throw to the client. defineAction returns
type ActionResult<T> =
| { ok: true; data: T }
| { ok: false; error: { code: AppErrorCode; field?: string } };
and the UI maps code to a dictionary key (errors.<code> in en.json and bn.json — Rule 3 applies to errors too). Messages are never built on the server: the server does not know which language the person is reading.
Error codes are a closed union in src/server/http/errors.ts: UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, VALIDATION, CONFLICT, RATE_LIMITED, FEATURE_OFF, PLAN_LIMIT (maxTables/maxStaff), ITEM_UNAVAILABLE, PACKAGE_CLOSED, INVALID_CHOICE, PROMO_INVALID, TOKEN_STALE, SESSION_ENDED, PAYMENT_FAILED, INTERNAL. Unknown exceptions become INTERNAL and are logged with a request id, never echoed.
Mappers are the only place database spelling meets domain spelling: dine_in ↔ "dine-in", Decimal → number, Date → ISO string, images[0] → line image, derived fields (qrUrl, tenantName, tableLabel) filled in. A mapper has a unit test per enum proving every value round-trips — adding an enum value without a mapping fails CI.
Validation lives in *.schema.ts (zod) and is shared with the form: the dashboard form imports the same schema for instant feedback, so client and server can never disagree about what is valid. Existing draft validators (branch-draft.ts, staff-invite.ts, signup.ts, custom-domain.ts) become the zod refinements rather than being rewritten.
Money crosses the wire as number in the tenant's major unit (৳350.00 → 350) and is stored as Decimal(12,2). Rounding happens once, in order-totals.ts.
Time is stored UTC; anything a human reads as a restaurant time (package windows, reservations, "today" in analytics) is evaluated in Tenant.timezone — src/hooks/use-restaurant-clock.ts already does this on the client, and the server reuses its logic.
Caching. Reads that every guest shares (menu, packages, tenant branding) are wrapped in unstable_cache with a tenant:<id>:menu tag; the matching write actions call revalidateTag. Staff screens are dynamic and live on Realtime. Detail in docs/09.
Audit. Every owner-, manager- or platform-initiated write calls audit.record({ action, entity, details }) inside the same transaction, so a write that rolls back leaves no entry and a write that commits always has one.
🟢 বাংলা সারাংশ: প্রতিটা মডিউলের নিয়ম: action কখনো ক্লায়েন্টে exception ছোঁড়ে না — { ok, data } বা { ok: false, error: { code } } ফেরত দেয়, আর UI সেই code-কে en.json/bn.json-এর কী-তে রূপান্তর করে (সার্ভার জানে না ব্যবহারকারী কোন ভাষায় পড়ছেন)। Error code একটা বন্ধ তালিকা। Mapper-ই একমাত্র জায়গা যেখানে ডাটাবেজের বানান (dine_in, Decimal, Date) ডোমেইনের বানানে ("dine-in", number, ISO string) বদলায়। Validation zod-এ, ফর্ম আর সার্ভার একই স্কিমা ব্যবহার করে। টাকা Decimal-এ সংরক্ষিত, রাউন্ডিং একবারই। সময় UTC-তে, কিন্তু রেস্টুরেন্টের সময় (প্যাকেজ, রিজার্ভেশন) রেস্টুরেন্টের টাইমজোনে। প্রতিটা write-এর অডিট একই transaction-এ।
7. What stays on the client — deliberately
Not everything in localStorage becomes a row. These stay where they are because they belong to a device or a viewer, not to the restaurant:
| Key | Why it stays |
|---|---|
ros-theme, ros-glass-intensity | A viewer's preference; must apply before first paint (Rule 4) — a database round-trip cannot |
ros-cart | The cart before "Place order" is scratch state; the order is the record. (Shared table carts are a later feature — docs/16 §7) |
ros-guest-name | What this phone calls itself at this table; snapshotted into addedBy / customerName |
ros-my-order-ids | Which orders this phone placed — a convenience for the tracking list; access is still checked server-side |
ros-active-branch | Which branch this owner is looking at on this device |
ros-onboarding-dismissed, ros-roadmap-watchlist | Per-viewer UI state |
| abandoned-cart timer | Per-device nudge |
ros-session is replaced, not migrated: Supabase Auth's cookies are the session, and SessionGuard is deleted (CLAUDE.md is explicit that a client-side guard is not a defence).
🟢 বাংলা সারাংশ: localStorage-এর সব কিছু ডাটাবেজে যাবে না। থিম, কার্ট, অতিথির নাম, এই ফোনের অর্ডার আইডি, সক্রিয় ব্রাঞ্চ, অনবোর্ডিং ডিসমিস — এগুলো ডিভাইস বা দর্শকের, রেস্টুরেন্টের না; তাই যেখানে আছে সেখানেই থাকবে। ros-session মাইগ্রেট হবে না, বাদ যাবে — Supabase Auth-এর কুকিই সেশন, আর SessionGuard মুছে ফেলা হবে।