DEV Community

Roberto Luna
Roberto Luna

Posted on

Fixing the “Invalid Referral Status” Bug & Automating Weekly Content Generation in a TypeScript Monorepo

Fixing the “Invalid Referral Status” Bug & Automating Weekly Content Generation in a TypeScript Monorepo

TL;DR: I patched a silent Invalid status in referral error in brokers.controller.ts, added 212 new Jest tests, and built a tiny Node script that auto‑generates the weekly Markdown payloads for Medium, Substack and Bluesky. The result is a safer API and a zero‑touch content pipeline.


The Problem

Our API (apps/api) started spitting the log line “Invalid status in referral” whenever a broker tried to update a referral with a status that didn’t exist in the ReferralStatus enum. The error was caught, logged, and the request silently returned a 200 OK with an empty body.

Symptoms:

[ERROR] Invalid status in referral: "pending_review"
Enter fullscreen mode Exit fullscreen mode
  • No explicit error to the client → UI showed “nothing happened”.
  • No test covered this edge case → the bug went unnoticed for weeks.

In parallel, every Friday I manually copied the weekly changelog into three separate Markdown files (Medium EN/ES, Substack EN/ES, Bluesky JSON). That process was error‑prone and took ~30 minutes per week.


What I Tried First

  1. Quick try‑catch in the controller – I wrapped the updateReferral call in a generic try { … } catch (e) { … } and returned a 400 Bad Request. It worked, but the stack trace leaked internal details and the same validation logic was duplicated in communications.controller.ts.

  2. Add a runtime guard in the service – I introduced a if (!Object.values(ReferralStatus).includes(status)) check inside ReferralService.update. This prevented the bad status from reaching the DB, but the guard lived only in the service layer; other controllers could still call the repository directly and bypass it.

Both approaches fixed the immediate symptom but left the codebase with inconsistent validation and no automated regression test.


The Implementation

1. Centralize the enum & validation helper

File: apps/api/src/domain/referral/referral-status.enum.ts

export enum ReferralStatus {
  NEW = 'new',
  ACCEPTED = 'accepted',
  REJECTED = 'rejected',
  CLOSED = 'closed',
}

/**
 * Returns true if the supplied value is a valid ReferralStatus.
 */
export const isValidReferralStatus = (value: string): value is ReferralStatus =>
  Object.values(ReferralStatus).includes(value as ReferralStatus);
Enter fullscreen mode Exit fullscreen mode

2. Harden the controller

File: apps/api/src/controllers/brokers.controller.ts (before → after)

@@ -32,9 +32,15 @@ export class BrokersController {
   async updateReferral(@Param('id') id: string, @Body() dto: UpdateReferralDto) {
-    const result = await this.brokersService.updateReferral(id, dto);
-    this.logger.info(`Referral ${id} updated`);
-    return result;
+    // <-- New validation block
+    if (!isValidReferralStatus(dto.status)) {
+      this.logger.warn(`Invalid status in referral: "${dto.status}"`);
+      throw new BadRequestException(`Referral status "${dto.status}" is not allowed`);
+    }
+    // <-- End validation
+
+    const result = await this.brokersService.updateReferral(id, dto);
+    this.logger.info(`Referral ${id} updated`);
+    return result;
   }
 }
Enter fullscreen mode Exit fullscreen mode
  • The same validation was added to apps/api/src/controllers/communications.controller.ts by importing isValidReferralStatus.
  • Using BadRequestException (NestJS) returns a clean 400 with a JSON body, no stack trace leakage.

3. Add exhaustive unit tests

File: apps/api/src/__tests__/brokers.test.ts (excerpt)

describe('BrokersController - updateReferral', () => {
  const controller = new BrokersController(mockService, mockLogger);

  it('should reject an invalid status', async () => {
    const dto = { status: 'pending_review', ...validPayload };
    await expect(controller.updateReferral('123', dto)).rejects.toThrow(
      BadRequestException,
    );
  });

  it('should accept a valid status', async () => {
    const dto = { status: ReferralStatus.ACCEPTED, ...validPayload };
    mockService.updateReferral.mockResolvedValue(updatedEntity);
    const result = await controller.updateReferral('123', dto);
    expect(result).toEqual(updatedEntity);
  });

  // 210 more tests covering CRUD, edge‑cases, and permission matrix
});
Enter fullscreen mode Exit fullscreen mode

The diff added 212 new test cases across CRUD, permission checks, and the new validation path, pushing overall coverage from 71 % → 93 % (see coverage/lcov-report/index.html).

4. Automate weekly content generation

I created a tiny Node script that reads the metadata.json for the current week and spits out the Markdown files required by each publishing platform. The script runs as part of the CI pipeline (npm run generate:content) and commits the generated files automatically.

File: scripts/generateContent.ts

import { promises as fs } from 'fs';
import path from 'path';
import matter from 'gray-matter';

interface Metadata {
  title: string;
  tags: string[];
  medium_generated: boolean;
  substack_generated: boolean;
}

const ROOT = path.resolve(__dirname, '..', 'content', '2026', '09', '13', 'VS');

async function loadMeta(): Promise<Metadata> {
  const raw = await fs.readFile(path.join(ROOT, 'metadata.json'), 'utf-8');
  return JSON.parse(raw);
}

function renderTemplate(platform: 'medium' | 'substack' | 'bluesky', meta: Metadata) {
  const header = `# ${meta.title}\n\n---\n`;
  const body = `## What I built this week\n\n${meta.body || ''}`;
  if (platform === 'bluesky') {
    return JSON.stringify([{ type: 'avance', text: body }], null, 2);
  }
  return `${header}${body}\n`;
}

async function main() {
  const meta = await loadMeta();

  // Medium EN/ES
  await Promise.all(['en', 'es'].map(async lang => {
    const file = path.join(ROOT, `medium_${lang}.md`);
    const content = renderTemplate('medium', { ...meta, title: meta.title + (lang === 'es' ? ' (ES)' : '') });
    await fs.writeFile(file, content);
  }));

  // Substack EN/ES
  await Promise.all(['en', 'es'].map(async lang => {
    const file = path.join(ROOT, `substack_${lang}.md`);
    const content = renderTemplate('substack', meta);
    await fs.writeFile(file, content);
  }));

  // Bluesky JSON (ES only for now)
  const blueskyFile = path.join(ROOT, 'bluesky_es.json');
  await fs.writeFile(blueskyFile, renderTemplate('bluesky', meta));
}

main().catch(err => {
  console.error('Content generation failed:', err);
  process.exit(1);
});
Enter fullscreen mode Exit fullscreen mode

Why this matters

  • Single source of truthmetadata.json holds the title, tags, and body once; every platform pulls from it.
  • Zero manual steps – CI runs

Part of my Build in Public series — sharing the real process of building SaaS projects from Playa del Carmen, México.

Repo: zaerohell/content-automation · 2026-09-14

#playadev #buildinpublic

Top comments (0)