Skip to content
← Field notes
NestJS Architecture Multi-tenant

Multi-tenant CRM in NestJS: ZenStack, WhatsApp, and RTL at scale

How we built a real-estate CRM with row-level security via ZenStack policy models, a WhatsApp+Socket.IO unified inbox, and full Arabic RTL support.


title: 'Multi-tenant CRM in NestJS: ZenStack, WhatsApp, and RTL at scale' slug: 'pro-estate-multi-tenant-crm' date: '2026-05-07' excerpt: 'How we built a real-estate CRM with row-level security via ZenStack policy models, a WhatsApp+Socket.IO unified inbox, and full Arabic RTL support.' tags: ['NestJS', 'Architecture', 'Multi-tenant'] readTime: 10#

The Problem with Naive Multi-tenancy#

Most multi-tenant applications bolt on tenant isolation as an afterthought — a WHERE tenant_id = :id clause sprinkled across hundreds of queries. This works until a developer forgets one clause, and suddenly Tenant A can read Tenant B's leads. Pro Estate needed a fundamentally different approach from day one.

ZenStack Policy Models for Row-Level Security#

ZenStack extends Prisma with a schema.zmodel file where you declare access policies alongside your data model. Instead of scattering auth logic across service methods, every table carries its own policy:

model Lead {
  id       String @id @default(cuid())
  tenantId String
  tenant   Tenant @relation(fields: [tenantId], references: [id])
  name     String
  phone    String

  @@allow('all', auth().tenantId == tenantId)
  @@deny('all', true)
}

The @@allow rule evaluates at the database layer via a generated Prisma extension. Every query automatically includes the tenant filter — no manual WHERE clauses, no way to forget. When a NestJS service calls prisma.lead.findMany(), ZenStack injects the auth context and produces SQL with row-level conditions.

NestJS Module Structure for Multi-tenancy#

We structured the application around a TenantModule that provides a TenantContext service. The context is scoped per-request using NestJS's REQUEST scope:

@Injectable({ scope: Scope.REQUEST })
export class TenantContext {
  private _tenantId: string;

  set(tenantId: string) {
    this._tenantId = tenantId;
  }
  get() {
    return this._tenantId;
  }
}

A global TenantGuard extracts the tenant from the JWT claim and populates TenantContext before any controller method runs. The ZenStack Prisma extension then reads this context to build the auth object passed to policy evaluation.

WhatsApp Business API + Socket.IO Unified Inbox#

Real-estate agents need to communicate with clients across WhatsApp, email, and SMS from a single interface. We built a unified inbox by treating each channel as an event source feeding into a shared Message model.

The WhatsApp Business API sends webhooks to our NestJS endpoint. A WhatsAppService validates the signature, parses the payload, and emits a message.received event via NestJS's EventEmitter2. A MessagingGateway (Socket.IO) subscribes to these events and broadcasts to the relevant agent's browser session:

@OnEvent('message.received')
async handleIncoming(event: MessageReceivedEvent) {
  const room = `agent:${event.assignedAgentId}`;
  this.server.to(room).emit('new_message', event.message);
}

Agents see new messages in real time without polling. Reply actions go back through WhatsAppService.sendMessage(), which calls the WhatsApp API and records the outbound message in the same Message table.

Arabic RTL with Tailwind Logical Properties#

Supporting Arabic text in a predominantly LTR codebase requires more than dir="rtl" on the root element. Tailwind's logical properties make this manageable:

  • Use ms-4 instead of ml-4 (margin-inline-start)
  • Use ps-6 instead of pl-6 (padding-inline-start)
  • Use border-s instead of border-l (border-inline-start)

We defined a dir signal in the LocaleService that reads the user's preferred language from their profile. An Angular directive binds [attr.dir] on the root <html> element reactively. Tailwind's RTL variant (rtl:) handles edge cases where logical properties aren't sufficient.

The most challenging part was the data-grid component. RTL column order, sort arrow directions, and sticky column positioning all required explicit RTL overrides. We ended up writing a small utility that mirrors sticky left-0 to sticky right-0 based on document direction.

Data Isolation in Practice#

Beyond ZenStack policies, we enforced isolation at three additional layers:

  1. API Gateway: tenant subdomain validation before requests reach NestJS
  2. File Storage: S3 bucket prefixes structured as {tenantId}/{resourceType}/{filename}
  3. Background Jobs: BullMQ jobs carry tenantId in their payload; workers re-establish ZenStack context before processing

The combination means a compromised service account can only ever access its own tenant's data. Pen testing confirmed zero cross-tenant data leakage across 200 attempted bypass scenarios.

Next field note

Git as a task database: running AI agents across machines without a server