DEXO Developer Guide
How the platform fits together, how to extend it, and step-by-step recipes for the most common kinds of work: integrating third-party APIs, adding OTP verification, building a new module, building a new app, and shipping a platform-level module (HR example) that tenants can enhance.
Companion docs: BRAND-GUIDE-FOR-DEVELOPERS.md, 05_Auth_RBAC_Design.md, 06_API_Design_Conventions.md.
1. System map
┌──────────────────────────────┐
│ apps/api :4000 │ NestJS — ALL business logic
│ src/modules/* + packages/* │ Prisma → Postgres :5433
└──────┬───────────┬───────────┘
platform surfaces │ │ tenant surfaces (per subdomain)
┌────────────────────────────┤ ├──────────────────────────────────────┐
│ platform-web :3001 marketing/signup │ tenant-website :4005 public site │
│ platform-admin :3002 Dexo staff admin │ tenant-admin :4006 owner + staff │
│ │ tenant-app :4007 customer PWA │
│ │ mobile (Expo) customer native│
└────────────────────────────────────────┴──────────────────────────────────────┘
Infra (docker-compose / run.bat): Postgres 5433 · Redis 6379 · MinIO 9000 · MailHog 1025/8025
- One API, many frontends. Every frontend talks to
http://localhost:4000/apithrough a small typed client (apps/<app>/lib/api.ts). Frontends never touch the DB. - One schema.
prisma/schema.prismaat the repo root. Every business row carriestenantId; platform rows havetenantId = null. - Shared packages (
packages/*):@dexo/auth(JWT, guards),@dexo/shared(Prisma, audit, queue, tenant mail),@dexo/ui(brand tokens, preset, logo), plus feature packages (billing, notification, settings, …) mounted byapps/api/src/app.module.ts.
2. Multi-tenancy & the business-template system
- Tenant = one business (row in
Tenant, uniquesubdomain). Tenant resolution: subdomain host (vrfitness.localhost),X-Dev-Tenantheader, orDEV_TENANTenv in dev. - DomainType = business vertical (
FITNESS_CENTER,RESTAURANT_AND_CAFE, …12 types). Each has aBusinessTypeTemplate(colors, website sections, onboarding steps, dashboard layout, features) seeded byscripts/seed/01-domain-templates.ts. Keep these — new capabilities extend templates, they don't replace them. - Provisioning: when a tenant is created,
domain-provisioning.service.tsenables the domain's modules/roles and seeds vertical defaults (e.g. fitness → HQ branch + starter membership plans). Add your module's defaults there. - Menus/dashboards: tenant-admin builds its sidebar from
apps/tenant-admin/lib/domain-config.ts(DOMAIN_MENUSper domain +COMMON_MENUSthat appear for every business type — attendance and email live there).
3. How auth works (read this before building anything)
- Login —
POST /api/auth/login { email, password, subdomain }→ access JWT (1h) + refresh token. The JWT payload carriessub(userId),email,tenantId,isPlatformAdmin. - Guards — controllers use
@UseGuards(JwtAuthGuard)from@dexo/auth; the strategy puts the payload onreq.user. Platform-only endpoints addPlatformAdminGuard. - Tenant scoping — every service method takes
tenantIdas its first argument and filterswhere: { tenantId }. Controllers passreq.user.tenantId. This is the security boundary — never trust a tenantId from the request body. - Roles —
Role/UserRolesper tenant; self-registration assignssignupAs(MEMBER/TRAINER) roles; admin signup only from the tenant-admin flow. - Frontend storage — tenant-admin stores the JWT under
tenant-token-<subdomain>; tenant-app underdexo_token; clients attachAuthorization: Bearer. - Emails — registration and password-reset emails are sent by
AuthServicethroughTenantMailService(@dexo/shared) — tenant SMTP first, platform SMTP fallback.
4. Anatomy of a backend module (the pattern to copy)
Look at apps/api/src/modules/attendance-devices/ or fitness/ — every module follows:
apps/api/src/modules/<name>/
<name>.module.ts # imports PrismaModule, declares providers/controllers
<name>.controller.ts # thin: guards + req.user.tenantId + delegation
<name>.service.ts # all logic; every method (tenantId, ...) and where:{tenantId}
Checklist to add one:
- Schema — add models to
prisma/schema.prisma(alwaystenantId+tenantrelation- back-relation on
Tenant). Generate a migration:npx prisma migrate diff→prisma/migrations/<ts>_<name>/migration.sql→npx prisma migrate deploy && npx prisma generate(Windows shells are non-interactive;migrate devwon't run).
- back-relation on
- Module — create the three files, register in
apps/api/src/app.module.ts. - Seed — add demo rows in
scripts/seed/(idempotent: count → top up). - Frontend — extend the app's
lib/api.tsclient group, add a page following an existing one (tenant-admin pages use the_ui.tsxprimitives), add a menu entry indomain-config.ts(a specific domain's list, orCOMMON_MENUSfor all). - Verify —
npx tsc --noEmitper app; run the API (npm run devin apps/api — note:nest startfails on monorepo rootDir; the repo runs via ts-node transpile-only).
5. Complete journey: platform admin → tenant admin → customer
- Platform admin (:3002,
admin@test.com) creates a tenant, picks a business type → provisioning enables domain modules/roles, seeds defaults, createsTenantLifecycle(subdomain slug, SSL state) andTenantOnboarding(6-step checklist). - Tenant admin (:4006,
<subdomain>login) completes onboarding: profile, branding (their colors/logo — the white-label semantic override), modules, team invites, website, billing. Sets up SMTP (Email settings), payment providers (payment-gateway), devices, plans. - Public site (:4005) renders the tenant's
BusinessTypeTemplate+ branding; customers discover the business and self-register. - Customer (:4007) registers (
signupAs: MEMBER→ welcome email → member profile auto-created for fitness tenants) → onboarding (goals) → buys a plan → pays online via the tenant's gateway → uses the vertical features (workouts, diet, check-in, chat, referrals). - Money & data flow back: payments create
PaymentTransaction+ GL journal entries; check-ins/attendance feed reports; platform admin sees cross-tenant analytics, billing, attendance and audit logs.
6. Recipe — integrate a third-party API (example: customer onboarding)
Pattern to copy: apps/api/src/modules/payment-gateway/ (provider interface + pluggable
providers + per-tenant credentials) — it generalizes to any third-party integration.
Say you want to push every completed customer onboarding to an external CRM:
- Per-tenant credentials — store in
Setting(key: 'crm', JSON value) likeTenantMailServicedoes, or in a dedicated table likePaymentProviderif you need status/fees. Never in env vars (env = platform-level only). - Provider interface —
ICrmProvider { pushLead(cfg, payload) }with one class per vendor (hubspot.provider.ts, …), selected bytype— exactly likepayment-provider.interface.ts. - Hook the event — onboarding completion lives in
apps/api/src/modules/onboarding/onboarding.service.ts; call your service best-effort:this.crm.pushLead(tenantId, dto).catch(...)— a vendor outage must never fail the user-facing flow (same rule as welcome emails). - Inbound webhooks — add an unauthenticated callback route with the tenant in the path,
POST /crm/callback/:tenantId, and verify the vendor signature — see the eSewa/Fonepay callbacks inpayment-gateway.controller.ts. - Admin UI — a settings card in tenant-admin (copy the Email (SMTP) page: load config → save → "send test").
7. Recipe — SMS OTP verification
Twilio credentials already exist in .env.example (TWILIO_*); MailHog-style dev fallback
is a console/mock sender.
- Schema — add:
model OtpCode { id String @id @default(uuid()) tenantId String? phone String codeHash String // bcrypt of the 6-digit code — never store plaintext purpose String // REGISTER | LOGIN | RESET | PAYMENT expiresAt DateTime attempts Int @default(0) usedAt DateTime? createdAt DateTime @default(now()) @@index([phone, purpose]) } - Module —
apps/api/src/modules/otp/:sms.provider.ts:send(to, text)via Twilio REST (plainfetch, likeai-plan.service.tscalls Anthropic); if noTWILIO_ACCOUNT_SID, log the code (mock mode) so dev flows work.otp.service.ts:request(phone, purpose)→ generate 6 digits, bcrypt-hash, save with 5-min expiry, rate-limit (max 3 active per phone), send SMS;verify(phone, purpose, code)→ find latest unused, check expiry + attempts (max 5), compare hash, markusedAt.- Controller:
POST /otp/request,POST /otp/verify(throttled — the API already hasThrottlerModule).
- Wire into auth — e.g. phone-verified registration: frontend calls
/otp/request, user enters code, frontend calls/otp/verify→ returns a short-livedotpToken(JWT,type: 'otp'), which is passed to/auth/registerand checked there. Password reset by SMS mirrors the email flow inauth.service.ts. - Frontend — a 6-digit input screen in tenant-app between register and onboarding.
8. Recipe — create a new app
- Scaffold under
apps/<name>(copytenant-appfor a customer surface ortenant-adminfor a staff surface — you get auth, api client, tokens and layout for free). - Add the folder to root
package.json→workspaces; pick the next free port (README "locked v5 port map") and set it in the app'sdevscript. - Wire the brand foundation (see BRAND-GUIDE §3):
next/fonttrio in the root layout,dexo-brand.cssimport, Tailwind preset (v3) or@themeblock (v4),app/icon.svg. - Add
@dexo/ui/@dexo/sharedtotranspilePackagesinnext.config.jsif you import them. - Copy
lib/api.tsfrom the nearest sibling and keep the same conventions (subdomain-scoped token,ApiResponse<T>, grouped endpoint objects). - Register the app in
run.bat(start list + port check) sorun.batboots it.
9. Recipe — platform-level module that tenants enhance (HR example)
The pattern: base module = platform-owned schema + API + default config; tenant enhancement = per-tenant configuration + vertical presets + their own UI arrangement. This is exactly how finance (chart-of-accounts template → tenant chart) and attendance (device registry → tenant devices) already work. For HR:
- Base (platform)
- Schema:
Employee(tenantId, branchId, userId?, designation, joinDate, salary…),LeaveType,LeaveRequest,PayrollRun— all tenant-scoped. - Module
apps/api/src/modules/hr/with CRUD + approval flows, reusing what exists: staff =User+BranchUser, attendance for timesheets (the ZKTeco module already provides punches/reports), finance for salary postings (copygym-ledger.service.ts— it shows how a vertical posts to the GL). - Platform admin page: cross-tenant HR stats (copy
app/attendance/page.tsx). - Register
hras aDomainModuleso provisioning can enable it per business type, and add defaultLeaveTyperows indomain-provisioning.service.ts.
- Schema:
- Tenant enhancement
BusinessTypeTemplate.featuresgains anhrblock per vertical (fitness: trainers as employees with session-based pay; restaurant: shift scheduling emphasis) — templates stay, they grow.- Tenant-admin pages under
app/(admin)/hr/*+ menu entry (add toCOMMON_MENUSif HR is for every business type, or per-domain lists if not). - Tenant-specific config (leave policy, payroll day, allowances) in
Setting(key: 'hr') — the same self-service override mechanism as SMTP. - Staff self-service (leave request, payslip) can live in tenant-admin's
(staff)area or tenant-app, calling the same API.
- Boundary — tenants configure and extend their HR data and screens; the base schema/API/behavior stays platform-owned so every tenant benefits from upgrades.
10. Conventions cheat-sheet
- NPR + 13% VAT for money in Nepal verticals;
Decimalin schema, never float. - Best-effort side effects (email, webhooks, AI) —
.catch()and log; never fail the main flow. - Seeds are idempotent (upsert or count→top-up); every table a feature reads should reach
≥10 demo rows (
npm run db:verify:fitnesspattern). - API responses: controllers return service results directly; errors via Nest exceptions.
- Windows dev: Postgres is Docker on 5433; API runs
ts-node --transpile-only;prisma migrate devis unavailable (non-interactive) — usemigrate diff+deploy.