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"
- 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
Quick try‑catch in the controller – I wrapped the
updateReferralcall in a generictry { … } catch (e) { … }and returned a400 Bad Request. It worked, but the stack trace leaked internal details and the same validation logic was duplicated incommunications.controller.ts.Add a runtime guard in the service – I introduced a
if (!Object.values(ReferralStatus).includes(status))check insideReferralService.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);
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;
}
}
- The same validation was added to
apps/api/src/controllers/communications.controller.tsby importingisValidReferralStatus. - Using
BadRequestException(NestJS) returns a clean400with 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
});
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);
});
Why this matters
-
Single source of truth –
metadata.jsonholds 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)