Skip to content

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

Flow diagram

Three rules make the picture work:

  1. Components never change. A hook keeps its returned interface; only its insides swap from useLocalStorage to a server action plus a Realtime subscription. This was the contract every hook was written to (docs/02 §4).
  2. 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.
  3. 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).

ModuleOwns (tables)Replaces (today)Main actions
tenantstenants, custom_domains, promo_landing_designsros-branding-images, ros-layout-width, ros-accent (tenant scope), ros-custom-domain, ros-qr-printed, ros-promo-designupdateBranding, uploadBrandImage, setLayoutWidth, requestDomain, verifyDomain, markQrPrinted
menumenu_categories, menu_items, variants, addons, ingredientsros-menu-images, ros-menu-allergensupsertCategory, upsertItem, setAvailability, setAllergens, addPhotos, reorder
packagesmeal_packages (+ items, choice groups/options)ros-packages, ros-package-imagescreatePackage, updatePackage, toggleActive, setSchedule
floorzones, restaurant_tables, staff_table_assignmentsros-floor-plan, ros-table-tokensaddZone, renameZone, hideZone, addTable, moveTable, rotateQr
sessionstable_sessions, table_callsros-table-session, ros-table-callsresolveScan (start/resume), callWaiter, requestBill, resolveCall, clearTable
ordersorders, order_line_items, order_timeline_events, order_countersros-live-orders, ros-my-order-idsplaceOrder, advanceStatus, cancel, markItemServed, assignRider, markPaid
paymentspayments— (cash only today)startPayment, handleIpn, refund
staffstaff_members, staff_invitesros-staff-invites, ros-session (staff part)invite, revokeInvite, acceptInvite, setPin, deactivate, pinSignIn
branchesbranchesros-branchesaddBranch, updateBranch, removeBranch
promospromosros-promoscreatePromo, updatePromo, toggle, validateCode
crmcrm_customers, loyalty_accounts, loyalty_transactions, reviewsros-loyaltyupsertCustomerFromOrder, earn, redeem, bonus, submitReview
reservationsreservations(seed only)create, confirm, seat, cancel, noShow
inventoryinventory_items, stock_movements, suppliers, purchase_orders(+lines)(seed only)adjustStock, createPurchaseOrder, receive
deliverydelivery_zones(seed only)upsertZone, quote
analytics— (read-only aggregates over orders)computed from mocksalesTrend, topItems, channelBreakdown, costing, insights
billingsubscription_plans, invoices, invoice_emails, vip_overridesros-platform-custom-plans / -plan-edits / -deleted-plans / -plan-overrides / -invoice-overrides / -vip-overrides / -generated-invoices / -invoice-email-logcreatePlan, editPlan, deletePlan, changeTenantPlan, issueInvoice, markPaid, grantVip, emailInvoice
featuresfeature_flags, tenant_feature_overridesros-platform-tenant-feature-overridestoggleGlobal, setTenantOverride — resolution stays src/lib/feature-resolution.ts
auditaudit_log_entriesros-platform-audit-logrecord() — called by other services, never by the UI
supportsupport_tickets, support_ticket_messagesros-platform-support-ticketsopenTicket, reply, setStatus
notificationsnotification_templates, notification_dispatches(seed only)upsertTemplate, dispatch (from orders.events)
platform-usersplatform_users(seed only)invite, setRole, suspend

Dependency rules

  • App → module: pages and hooks import @/server/modules/<m> (its index.ts) and nothing deeper.
  • Module → module: only through the other module's index.ts, and only downward: orders may call menu, packages, promos, crm, audit, notifications; nothing calls orders except payments and sessions. 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/lib must 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:

CallerTenant fromGuard
Guest on /table, /promo, /tablesHost header → proxy.ts → [tenant] param → getTenant(slug)requireTableToken(tableId, qrToken) for anything that writes
Dashboard, KDS, waiter, ridersame route paramrequireStaff(tenantId, roles) — session user must have a live staff_members row there with one of roles
Platform adminnone — cross-tenantrequirePlatform() → withPlatform
Webhooklooked up from the payment/provider reference, never from the body's claimsignature verification first
Croniterates tenantsCRON_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
});
Sequence diagram

What the service does, in order, inside one withTenant transaction:

  1. 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.
  2. Gate. Tenant is active/trial; resolveFeature allows the channel (delivery, packages); for dine-in, requireTableToken — the token must be the table's current qrToken (an old print fails) and the session must be the table's open one.
  3. Price from the database. Load the referenced menu items, variants, addons and packages. Unavailable items, a package outside its schedule (isPackageAvailableNow, evaluated in Tenant.timezone), a choice group with the wrong number of picks → AppError("ITEM_UNAVAILABLE" | "PACKAGE_CLOSED" | "INVALID_CHOICE").
  4. Totals via src/lib/order-totals.ts — the same function the cart used — plus the promo (promos.validateCode) and delivery zone fee/minimum.
  5. Number via select app.next_order_number($tenantId).
  6. Write order + line snapshots (name, tile, first photo, packageContents) + first timeline event.
  7. CRM — crm.upsertCustomerFromOrder when a phone was given; loyalty earn happens on completion, not placement.
  8. 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:

KeyWhy it stays
ros-theme, ros-glass-intensityA viewer's preference; must apply before first paint (Rule 4) — a database round-trip cannot
ros-cartThe cart before "Place order" is scratch state; the order is the record. (Shared table carts are a later feature — docs/16 §7)
ros-guest-nameWhat this phone calls itself at this table; snapshotted into addedBy / customerName
ros-my-order-idsWhich orders this phone placed — a convenience for the tracking list; access is still checked server-side
ros-active-branchWhich branch this owner is looking at on this device
ros-onboarding-dismissed, ros-roadmap-watchlistPer-viewer UI state
abandoned-cart timerPer-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 মুছে ফেলা হবে।