{"slug": "implementing-multi-tenancy-scoping-across-the-vs-api-a-step-by-step-code", "title": "Implementing Multi‑Tenancy Scoping Across the VS API – A Step‑by‑Step Code Walkthrough", "summary": "A developer completed Phase 4 of a multi-tenancy migration for the VS API, a real-estate monolith that previously returned data across all organizations. The work scopes every data-access point to organization_id using a new TenantContext utility that extracts the tenant from any NestJS execution context, including HTTP requests and background jobs. Controllers and services were updated to pass the organization ID down rather than reading it from raw Request objects.", "body_md": "**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.\n\nOur 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:\n\n```\nFAIL  apps/api/src/__tests__/stats.test.ts\n  ✕ should return stats only for the requested organization (500 ms)\n\n  Expected: {\"organizationId\":\"org_123\",\"visits\":42}\n  Received: {\"organizationId\":\"org_123\",\"visits\":42,\"otherOrgVisits\":17}\n```\n\nThe 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.\n\nMy 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:\n\n```\n@Injectable()\nexport class StatsService {\n  async getStats(req: Request) {\n    const orgId = req['organizationId']; // <-- first try\n    return this.db.stats.findMany({ where: { organizationId: orgId } });\n  }\n}\n```\n\nTwo problems surfaced quickly:\n\n`@Req()` while others used the `ExecutionContext` directly, leading to `undefined` values.`Request` parameter just to fetch the tenant, polluting method signatures and making unit testing harder.\nThe guard approach also failed to cover background jobs (cron, queue workers) that run outside an HTTP request.\n\nI 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`:\n\n``` js\n// apps/api/src/tenancy/tenant-context.ts\nimport { ExecutionContext } from '@nestjs/common';\nimport { Request } from 'express';\n\nexport class TenantContext {\n  static getOrganizationId(context: ExecutionContext): string {\n    const http = context.switchToHttp();\n    const request = http.getRequest<Request>();\n    if (request?.user?.organizationId) {\n      return request.user.organizationId;\n    }\n    // Fallback for non‑HTTP contexts (e.g., cron jobs)\n    const data = context.switchToRpc().getData();\n    if (data?.organizationId) return data.organizationId;\n    throw new Error('Organization ID not found in context');\n  }\n}\n```\n\nAll services now receive the `ExecutionContext` instead of a raw `Request`. This keeps the API surface clean and works for background jobs.\n\nEach 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):\n\n```\n--- a/apps/api/src/construction/construction.controller.ts\n+++ b/apps/api/src/construction/construction.controller.ts\n@@ -12,6 +12,7 @@ import { RequirePerm } from '../auth/permissions.decorator';\n import { ConstructionService } from './construction.service';\n import { CreateProjectDto } from './dto/create-project.dto';\n+import { ExecutionContext } from '@nestjs/common';\n+import { TenantContext } from '../tenancy/tenant-context';\n\n @Controller('construction')\n export class ConstructionController {\n@@ -17,20 +18,21 @@ export class ConstructionController {\n   // ── Proyecto de obra (auto-crea si no existe al primer GET) ───────────\n   @Get()\n   @RequirePerm(\"properties:read\")\n-  async getProject(\n-    @Query('propertyId') propertyId: string,\n-    @Req() req: Request,\n-  ) {\n-    const orgId = req.user.organizationId;\n-    return await this.constructionService.getOrCreateProject(propertyId, orgId);\n+  async getProject(@Query('propertyId') propertyId: string, @Req() req: Request, @Context() ctx: ExecutionContext) {\n+    const orgId = TenantContext.getOrganizationId(ctx);\n+    return await this.constructionService.getOrCreateProject(propertyId, orgId);\n   }\n```\n\nKey points:\n\n`@Context() ctx: ExecutionContext` (Nest’s built‑in injection) to keep the method signature identical for testing.`orgId` explicitly.\nEach service that touched the DB was updated to accept `organizationId` as the first argument. Example from `virtual-tour.service.ts`:\n\n```\n--- a/apps/api/src/virtual-tour/virtual-tour.service.ts\n+++ b/apps/api/src/virtual-tour/virtual-tour.service.ts\n@@ -90,18 +90,18 @@ export class VirtualTourService {\n   }\n\n   /** Trae (o crea) el registro de tour para una propiedad. */\n-  async getOrCreate(propertyId: string) {\n-    const existing = await db<...>.findFirst({ where: { propertyId } });\n+  async getOrCreate(organizationId: string, propertyId: string) {\n+    const existing = await db<...>.findFirst({ where: { propertyId, organizationId } });\n     if (existing) return existing;\n-    return await db.tour.create({ data: { propertyId } });\n+    return await db.tour.create({ data: { propertyId, organizationId } });\n   }\n```\n\nAll calls to `getOrCreate` in `virtual-tour.controller.ts` were patched accordingly:\n\n```\n--- a/apps/api/src/virtual-tour/virtual-tour.controller.ts\n+++ b/apps/api/src/virtual-tour/virtual-tour.controller.ts\n@@ -30,28 +30,28 @@ export class VirtualTourController {\n   @Get()\n   async status(@Param(\"id\") id: string, @Context() ctx: ExecutionContext) {\n-    return await this.tourService.getOrCreate(id);\n+    const orgId = TenantContext.getOrganizationId(ctx);\n+    return await this.tourService.getOrCreate(orgId, id);\n   }\n```\n\nPhase 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:\n\n| File | What was scoped | Example change | \n|---|---|---|\n| `apps/api/src/brokers/brokers.controller.ts` | `broker` queries | `where: { organizationId }` | \n| `apps/api/src/whatsapp/whatsapp.controller.ts` | WhatsApp messages | `findMany({ where: { organizationId } })` | \n| `apps/api/src/whatsapp-ai/whatsapp-ai.service.ts` | AI prompt logs | `create({ data: { organizationId, ... } })` | \n| `apps/api/src/notifications/web-push.controller.ts` | Push subscriptions | `subscription.organizationId = orgId` | \n| `apps/api/src/stats/stats.controller.ts` | Aggregations | `groupBy({ by: ['organizationId'], ... })` | \n| `apps/api/src/feed/share-links.controller.ts` | Share links | `findUnique({ where: { id, organizationId } })` | \n| `apps/api/src/seasonal-pricing/seasonal-pricing.controller.ts` | Pricing tables | `where: { organizationId, season }` | \n| `apps/api/src/db/db.ts` | Connection pool | Added `attachPoolErrorHandler()` (see commit 933b7ef2) | \n| `apps/api/src/ **tests** /stats.test |  |  | \n\n*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building Building PlayaMXCRM from Playa del Carmen, México.*\n\n*Repo: `zaerohell/VS` · 2026-09-10*\n\n#playadev #buildinpublic", "url": "https://wpnews.pro/news/implementing-multi-tenancy-scoping-across-the-vs-api-a-step-by-step-code", "canonical_source": "https://dev.to/zaerohell/implementing-multi-tenancy-scoping-across-the-vs-api-a-step-by-step-code-walkthrough-1mio", "published_at": "2026-09-11 18:01:14+00:00", "updated_at": "2026-09-11 18:13:53.480005+00:00", "lang": "en", "topics": ["developer-tools", "ai-products"], "entities": ["VS API", "NestJS", "TenantContext", "ConstructionService", "StatsService"], "alternates": {"html": "https://wpnews.pro/news/implementing-multi-tenancy-scoping-across-the-vs-api-a-step-by-step-code", "markdown": "https://wpnews.pro/news/implementing-multi-tenancy-scoping-across-the-vs-api-a-step-by-step-code.md", "text": "https://wpnews.pro/news/implementing-multi-tenancy-scoping-across-the-vs-api-a-step-by-step-code.txt", "jsonld": "https://wpnews.pro/news/implementing-multi-tenancy-scoping-across-the-vs-api-a-step-by-step-code.jsonld"}}