Build1 publisher2 min readPublished
A stats assertion caught 17 visits that belonged to other organizations
The VS API was built for one tenant. A guard that stashed the tenant on the request returned undefined in some controllers and skipped cron jobs, so organization_id became an argument on every database-touching service method.
The Engineer · Build desk

What happened
- The VS API monolith was written for one tenant, and once several real-estate agencies were onboarded every endpoint returned rows for all organizations, with performance regressions alongside the privacy failure.
- The symptom was an assertion in stats.test.ts, where a payload expected to hold organizationId and visits came back with an extra otherOrgVisits field carrying 17 visits from other organizations.
- The first attempt, a NestJS guard that put the JWT tenant on request.organizationId, produced undefined values in some controllers and left cron jobs and queue workers uncovered.
- Phase 4 replaced it with a TenantContext.getOrganizationId helper and an explicit organizationId argument threaded through dozens of controllers and services.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint Isolation now depends on every call site passing the first argument, so coverage is only as good as the last engineer who remembered it; nothing beneath the query layer refuses an unscoped read.
- exposure Background jobs are the paths no HTTP test exercises, and a worker that reaches the database without an organizationId in its RPC payload gets the same unfiltered read the endpoints used to hand out.
- cost Every database-touching service method changed shape, so call sites and unit tests across the migration pay the churn before any new tenant feature ships.
- decision With enforcement at call sites, code review and the test suite become the only control on a new endpoint, because a missed filter ships as a valid response rather than an error anyone gets paged for.
The helper does three things in order. It calls context.switchToHttp(), takes the Express request, and returns request.user.organizationId when that is set [10]. When it is not, it calls context.switchToRpc().getData() and looks for organizationId on the payload; the comment above that branch labels it a fallback for non-HTTP contexts such as cron jobs [11]. When both miss, it throws Error('Organization ID not found in context') [12].
The throw is the good part. The first attempt's failure mode was a tenant id of undefined, produced because some controllers used @Req() and others read the ExecutionContext directly [7].
I would ask a question about the RPC fallback at review. getOrganizationId takes exactly one parameter, an ExecutionContext [10]. A cron tick does not arrive with one, so something has to construct an object whose switchToRpc().getData() returns an organizationId before a queue worker can call this at all [19]. The diffs in the walkthrough cover construction.controller.ts and the opening of virtual-tour.service.ts, and no background-job call site is among them [20]. Background jobs were the third of three reasons given for abandoning the guard [21].
construction.controller.ts, described as the biggest file in Phase 4, gains @Context() ctx: ExecutionContext and keeps @Req() req: Request, with the body swapping req.user.organizationId for TenantContext.getOrganizationId(ctx) [14]. The post says the injection is there "to keep the method signature identical for testing" [15]. The signature in the diff has one parameter more than before, and the Request the boilerplate complaint was about is still in it [14][8].
The expected payload had organizationId and visits; the received payload had both of those, correct, plus otherOrgVisits: 17 [3]. That is one field more than the test expected [18]. Nothing errored, and a client that ignores unknown fields would take a 200 and a superset. Both the privacy failure and the performance regressions came from the same place, most controllers running raw database queries with no organization_id filter [4][1].
Every service method that touches the database now takes organizationId as its first argument [16], and controllers pass it down explicitly [17]. I would normally push that filter under the query layer so that a new call site cannot forget it. The constraint arguing against that sits in the post's own diagnosis: global services such as whatsapp-ai.service.ts share the codebase with tenant-specific logic, which is what made runtime enforcement impossible in the first place [5]. A default filter under the ORM would need a documented escape hatch for the services meant to read across organizations. Given that mix, threading the argument through the construction, brokers and stats modules is the trade-off I would make too [2].
What to watch
- Whether a later phase publishes a cron or queue call site that builds the RPC context, since the shown diffs stop at the HTTP path.
- Whether the global services such as whatsapp-ai.service.ts get an explicit allow-list; that is the precondition for moving the filter under the ORM.
- Whether a test exists that fails when any new query site omits organization_id, not just the stats endpoint.