DEV Community

Roberto Luna
Roberto Luna

Posted on

Isolating Multi‑Tenant Email Cron Jobs in a Next.js/Node Monorepo

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
Enter fullscreen mode Exit fullscreen mode

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 },
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. Discovery – fetch a distinct list of organization_ids that have pending emails.
  2. 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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…
}
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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

  1. Parallelize tenant processing – spawn a worker per organization using Promise.allSettled while respecting rate limits.
  2. Add per‑tenant metrics – push success/failure counters to Prometheus with a tenant label.
  3. 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)