Implementing Multi‑Tenancy Scoping Across the VS API – A Step‑by‑Step Code Walkthrough
TL;DR: I finished Phase 4 of the multi‑tenancy migration by scoping every data‑access point to organization_id. The change lives in dozens of controllers/services (construction, brokers, stats, etc.) and required a handful of helper utilities to keep the code DRY and safe.
The Problem
Our monolithic VS API was built for a single tenant. When we started onboarding multiple real‑estate agencies, every endpoint returned data for all organizations, which broke privacy and caused massive performance regressions. The symptom showed up in the test suite:
FAIL apps/api/src/__tests__/stats.test.ts
✕ should return stats only for the requested organization (500 ms)
Expected: {"organizationId":"org_123","visits":42}
Received: {"organizationId":"org_123","visits":42,"otherOrgVisits":17}
The root cause: most controllers performed raw DB queries without filtering by organization_id. The code base also mixed “global” services (e.g., whatsapp-ai.service.ts) with tenant‑specific logic, making it impossible to enforce isolation at runtime.
What I Tried First
My first attempt was to add a NestJS Guard that injected the tenant from the JWT and stored it in request.organizationId. I then tried to read that value inside each service:
@Injectable()
export class StatsService {
async getStats(req: Request) {
const orgId = req['organizationId']; // <-- first try
return this.db.stats.findMany({ where: { organizationId: orgId } });
}
}
Two problems surfaced quickly:
-
Inconsistent injection – Some controllers used
@Req()while others used theExecutionContextdirectly, leading toundefinedvalues. -
Boilerplate explosion – Every service now required a
Requestparameter just to fetch the tenant, polluting method signatures and making unit testing harder.
The guard approach also failed to cover background jobs (cron, queue workers) that run outside an HTTP request.
The Implementation
1. Central “TenantContext” Helper
I introduced a tiny utility that extracts the tenant from any NestJS context (HTTP, RPC, or custom). The file lives at apps/api/src/tenancy/tenant-context.ts:
// apps/api/src/tenancy/tenant-context.ts
import { ExecutionContext } from '@nestjs/common';
import { Request } from 'express';
export class TenantContext {
static getOrganizationId(context: ExecutionContext): string {
const http = context.switchToHttp();
const request = http.getRequest<Request>();
if (request?.user?.organizationId) {
return request.user.organizationId;
}
// Fallback for non‑HTTP contexts (e.g., cron jobs)
const data = context.switchToRpc().getData();
if (data?.organizationId) return data.organizationId;
throw new Error('Organization ID not found in context');
}
}
All services now receive the ExecutionContext instead of a raw Request. This keeps the API surface clean and works for background jobs.
2. Refactoring Controllers
Each controller was updated to call TenantContext.getOrganizationId(context) and pass the orgId down to its service. Below is the diff for construction.controller.ts (the biggest file in Phase 4):
--- a/apps/api/src/construction/construction.controller.ts
+++ b/apps/api/src/construction/construction.controller.ts
@@ -12,6 +12,7 @@ import { RequirePerm } from '../auth/permissions.decorator';
import { ConstructionService } from './construction.service';
import { CreateProjectDto } from './dto/create-project.dto';
+import { ExecutionContext } from '@nestjs/common';
+import { TenantContext } from '../tenancy/tenant-context';
@Controller('construction')
export class ConstructionController {
@@ -17,20 +18,21 @@ export class ConstructionController {
// ── Proyecto de obra (auto-crea si no existe al primer GET) ───────────
@Get()
@RequirePerm("properties:read")
- async getProject(
- @Query('propertyId') propertyId: string,
- @Req() req: Request,
- ) {
- const orgId = req.user.organizationId;
- return await this.constructionService.getOrCreateProject(propertyId, orgId);
+ async getProject(@Query('propertyId') propertyId: string, @Req() req: Request, @Context() ctx: ExecutionContext) {
+ const orgId = TenantContext.getOrganizationId(ctx);
+ return await this.constructionService.getOrCreateProject(propertyId, orgId);
}
Key points:
- The controller now receives
@Context() ctx: ExecutionContext(Nest’s built‑in injection) to keep the method signature identical for testing. - All downstream calls receive
orgIdexplicitly.
3. Service Layer Adjustments
Each service that touched the DB was updated to accept organizationId as the first argument. Example from virtual-tour.service.ts:
--- a/apps/api/src/virtual-tour/virtual-tour.service.ts
+++ b/apps/api/src/virtual-tour/virtual-tour.service.ts
@@ -90,18 +90,18 @@ export class VirtualTourService {
}
/** Trae (o crea) el registro de tour para una propiedad. */
- async getOrCreate(propertyId: string) {
- const existing = await db<...>.findFirst({ where: { propertyId } });
+ async getOrCreate(organizationId: string, propertyId: string) {
+ const existing = await db<...>.findFirst({ where: { propertyId, organizationId } });
if (existing) return existing;
- return await db.tour.create({ data: { propertyId } });
+ return await db.tour.create({ data: { propertyId, organizationId } });
}
All calls to getOrCreate in virtual-tour.controller.ts were patched accordingly:
--- a/apps/api/src/virtual-tour/virtual-tour.controller.ts
+++ b/apps/api/src/virtual-tour/virtual-tour.controller.ts
@@ -30,28 +30,28 @@ export class VirtualTourController {
@Get()
async status(@Param("id") id: string, @Context() ctx: ExecutionContext) {
- return await this.tourService.getOrCreate(id);
+ const orgId = TenantContext.getOrganizationId(ctx);
+ return await this.tourService.getOrCreate(orgId, id);
}
4. Scoping Other Domains
Phase 4 required us to repeat the same pattern across 10+ modules. Below is a quick inventory of the files touched and the specific scoping added:
| File | What was scoped | Example change |
|---|---|---|
apps/api/src/brokers/brokers.controller.ts |
broker queries |
where: { organizationId } |
apps/api/src/whatsapp/whatsapp.controller.ts |
WhatsApp messages | findMany({ where: { organizationId } }) |
apps/api/src/whatsapp-ai/whatsapp-ai.service.ts |
AI prompt logs | create({ data: { organizationId, ... } }) |
apps/api/src/notifications/web-push.controller.ts |
Push subscriptions | subscription.organizationId = orgId |
apps/api/src/stats/stats.controller.ts |
Aggregations | groupBy({ by: ['organizationId'], ... }) |
apps/api/src/feed/share-links.controller.ts |
Share links | findUnique({ where: { id, organizationId } }) |
apps/api/src/seasonal-pricing/seasonal-pricing.controller.ts |
Pricing tables | where: { organizationId, season } |
apps/api/src/db/db.ts |
Connection pool | Added attachPoolErrorHandler() (see commit 933b7ef2) |
| `apps/api/src/tests/stats.test |
Part of my Build in Public series — sharing the real process of building Building PlayaMXCRM from Playa del Carmen, México.
Repo: zaerohell/VS · 2026-09-10
#playadev #buildinpublic
Top comments (0)