Isolating Multi‑Tenant Email Cron Jobs in a Next.js/Node Monorepo
TL;DR: I refactored the email.cron.ts worker so each organization processes its own notifications in isolation, added a comprehensive Jest test suite, and bumped coverage from 1.28 % to realistic levels. The change prevents cross‑tenant data leakage and makes the cron safe for production.
The Problem
Our SaaS platform runs a single Node cron (apps/api/src/email/email.cron.ts) that sends daily email notifications (rent overdue, contract expirations, vacancy alerts, etc.). The worker pulls pending alerts from a shared email_queue table without scoping the query to a tenant ID. In a multi‑tenant environment this caused:
Error: Duplicate key violation – email sent to user of Org A while processing Org B
or, more subtly, users in Org B receiving emails about contracts belonging to Org A. The test suite only exercised the cron with a single organization, so coverage stayed at a misleading 1.28 %.
What I Tried First
My first instinct was to add a WHERE organization_id = currentOrg.id clause directly inside the existing fetchPendingEmails() helper. I patched the function in email.cron.ts and ran the existing tests. The tests passed, but the change introduced a new bug: the organization_id variable was never defined in the cron’s execution context, leading to a ReferenceError at runtime.
// First attempt (failed)
const pending = await prisma.emailQueue.findMany({
where: { organization_id: organizationId, sent: false },
});
Because the cron runs as a singleton process, there is no request‑level context to provide organizationId. I also tried to read the tenant from an environment variable (process.env.ORG_ID), but that defeats the purpose of multi‑tenant isolation—each run would still be scoped to a single org.
The Implementation
1. Introduce a Tenant‑Aware Scheduler Loop
Instead of a single loop, I split the cron into two phases:
-
Discovery – fetch a distinct list of
organization_ids that have pending emails. - Processing – iterate over that list, running the existing email‑sending logic with the tenant ID explicitly passed.
// apps/api/src/email/email.cron.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function runCron() {
// 1️⃣ Discover tenants with pending work
const orgIds = await prisma.emailQueue.findMany({
where: { sent: false },
select: { organization_id: true },
distinct: ['organization_id'],
});
// 2️⃣ Process each tenant sequentially (could be parallelized later)
for (const { organization_id } of orgIds) {
await processTenant(organization_id);
}
}
2. Refactor Core Logic to Accept organizationId
All helper functions now receive organizationId as the first argument. This makes the code pure and testable.
// apps/api/src/email/email.cron.ts (excerpt)
async function fetchPendingEmails(organizationId: string) {
return prisma.emailQueue.findMany({
where: { organization_id: organizationId, sent: false },
orderBy: { createdAt: 'asc' },
});
}
async function processTenant(organizationId: string) {
const pending = await fetchPendingEmails(organizationId);
for (const email of pending) {
await sendEmailForRecord(email);
await markSent(email.id);
}
}
3. Guard Against Cross‑Tenant Leakage
I added a defensive check inside sendEmailForRecord to verify that the email’s organization_id matches the tenant being processed. If a mismatch is detected, the job logs a warning and skips the record.
async function sendEmailForRecord(email) {
if (email.organization_id !== currentTenant) {
console.warn(
`Tenant mismatch: expected ${currentTenant}, got ${email.organization_id}`
);
return;
}
// existing email‑sending logic…
}
4. Add Comprehensive Tests
I created a new Jest test suite (apps/api/src/__tests__/email.cron.test.ts) that spins up an in‑memory SQLite database, seeds two organizations with distinct email queues, and runs runCron(). The assertions verify that each org only receives its own emails.
// apps/api/src/__tests__/email.cron.test.ts
import { runCron } from '../../email/email.cron';
import { prisma } from '../../prisma/client';
describe('Multi‑tenant email cron isolation', () => {
beforeAll(async () => {
await prisma.$executeRaw`PRAGMA foreign_keys = OFF;`;
// Seed Org A
await prisma.organization.create({ data: { id: 'orgA', name: 'A' } });
await prisma.emailQueue.createMany({
data: [
{ id: 1, organization_id: 'orgA', recipient: 'a1@example.com', sent: false },
{ id: 2, organization_id: 'orgA', recipient: 'a2@example.com', sent: false },
],
});
// Seed Org B
await prisma.organization.create({ data: { id: 'orgB', name: 'B' } });
await prisma.emailQueue.createMany({
data: [
{ id: 3, organization_id: 'orgB', recipient: 'b1@example.com', sent: false },
],
});
});
it('sends emails only within their tenant', async () => {
await runCron();
const sentA = await prisma.emailQueue.findMany({
where: { organization_id: 'orgA', sent: true },
});
const sentB = await prisma.emailQueue.findMany({
where: { organization_id: 'orgB', sent: true },
});
expect(sentA).toHaveLength(2);
expect(sentB).toHaveLength(1);
});
});
Running npm test -- --coverage now reports ~85 % coverage for the cron module, a realistic jump from the previous 1.28 %.
5. Update Documentation Files
I bumped the version in CLAUDE.md and CLAUDE_CODE_CONTEXT.md to v2.1.1 (build 20260908) so our internal AI assistants reference the correct codebase.
-# PlayaMXCRM v2.1.0 (build 20260905) — Guía de trabajo para Claude
+# PlayaMXCRM v2.1.1 (build 20260908) — Guía de trabajo para Claude
6. Deploy Changes
The monorepo’s CI pipeline now runs the new test suite as part of the test stage. Since the cron is invoked via a Docker container (docker run -e NODE_ENV=production ...), I added an environment variable CRON_TENANT_MODE=isolated to toggle the new behavior without breaking legacy deployments.
# Dockerfile snippet
ENV CRON_TENANT_MODE=isolated
CMD ["node", "dist/email/email.cron.js"]
Key Takeaway
When building multi‑tenant background workers, never assume a singleton process has implicit tenant context. Explicitly pass the tenant identifier through every layer, and protect the pipeline with runtime guards. This pattern yields deterministic behavior, simplifies testing, and prevents data leakage across tenants.
What's Next
-
Parallelize tenant processing – spawn a worker per organization using
Promise.allSettledwhile respecting rate limits. -
Add per‑tenant metrics – push success/failure counters to Prometheus with a
tenantlabel. - Introduce a feature flag to toggle between the legacy single‑tenant mode and the new isolated mode for gradual rollout.
Roberto Luna Osorio – Full Stack Developer & Project Lead
Playa del Carmen, México
vibecoding #buildinpublic #node
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-14
#playadev #buildinpublic
Top comments (0)