Building a Reliable WhatsApp‑Powered First‑Response Scheduler in a Node/TypeScript API
TL;DR: I fixed a race condition in apps/api/src/booking/booking.service.ts that prevented the first‑response agenda from persisting, and added a comprehensive test suite (primera‑respuesta‑y‑agenda.test.ts) to guard against regressions. The change now reliably records prospect appointments and updates the “first‑response” metrics used in our weekly VS newsletter.
The Problem
Our sales funnel relies on a WhatsApp bot that schedules a call as soon as a prospect replies. After deploying PR #27, the bot stopped creating calendar entries for the first reply. The symptom was a silent failure: the API returned 200 OK, but the appointments table stayed empty, and the metric “first‑response rate” stayed at 0 % in the weekly VS report.
The logs showed the following error from the booking service:
ERROR [BookingService] – Unable to persist agenda entry: TypeError: Cannot read property 'save' of undefined
at BookingService.recordFirstResponse (src/booking/booking.service.ts:112:27)
at processTicksAndRejections (node:internal/process/task_queues:96:5)
The root cause was a race condition where the AgendaRepository instance was being overwritten by a stale reference during concurrent requests, leading to undefined when save was called.
What I Tried First
My first attempt was to add a simple null‑check around the repository call:
if (this.agendaRepo) {
await this.agendaRepo.save(entry);
}
That prevented the exception but silently dropped the appointment, so the metric remained wrong. I also tried wrapping the whole method in a try/catch and returning a generic error, but that only masked the issue without fixing the underlying state‑sharing bug.
The Implementation
1. Refactor the repository injection
The service was using a singleton instance created at module load time:
// src/booking/booking.service.ts (pre‑fix)
import { AgendaRepository } from '../repositories/agenda.repository';
export class BookingService {
private agendaRepo = new AgendaRepository(); // <-- problem
}
I switched to dependency injection via NestJS’s provider system, ensuring each request receives a fresh repository scoped to the current request context.
// src/booking/booking.service.ts (post‑fix)
import { Injectable, Inject } from '@nestjs/common';
import { AgendaRepository } from '../repositories/agenda.repository';
@Injectable()
export class BookingService {
constructor(
@Inject('AgendaRepository') private readonly agendaRepo: AgendaRepository,
) {}
async recordFirstResponse(prospectId: string, payload: any): Promise<void> {
const entry = this.buildEntry(prospectId, payload);
await this.agendaRepo.save(entry); // now safe
}
}
The provider registration in app.module.ts:
// src/app.module.ts
@Module({
providers: [
{
provide: 'AgendaRepository',
useClass: AgendaRepository, // default transient scope
},
BookingService,
],
})
export class AppModule {}
2. Add explicit transaction handling
To guarantee atomicity between creating the agenda entry and updating the prospect’s firstResponseAt timestamp, I wrapped both operations in a TypeORM transaction:
// src/booking/booking.service.ts (continued)
import { Connection } from 'typeorm';
@Injectable()
export class BookingService {
constructor(
@Inject('AgendaRepository') private readonly agendaRepo: AgendaRepository,
private readonly connection: Connection,
) {}
async recordFirstResponse(prospectId: string, payload: any): Promise<void> {
await this.connection.transaction(async manager => {
const entry = this.buildEntry(prospectId, payload);
await manager.save(entry);
await manager.update(Prospect, { id: prospectId }, { firstResponseAt: new Date() });
});
}
}
3. Add a dedicated test suite
The commit 815423d0 introduced a 337‑line test file apps/api/src/__tests__/primera‑respuesta‑y‑agenda.test.ts. Below is a representative excerpt that validates the new flow:
// apps/api/src/__tests__/primera‑respuesta‑y‑agenda.test.ts
import { Test, TestingModule } from '@nestjs/testing';
import { BookingService } from '../../booking/booking.service';
import { AgendaRepository } from '../../repositories/agenda.repository';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Prospect } from '../../entities/prospect.entity';
describe('BookingService – First Response & Agenda', () => {
let service: BookingService;
let agendaRepo: AgendaRepository;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BookingService,
{
provide: getRepositoryToken(AgendaRepository),
useValue: {
save: jest.fn().mockResolvedValue(undefined),
},
},
{
provide: getRepositoryToken(Prospect),
useValue: {
update: jest.fn().mockResolvedValue(undefined),
},
},
],
}).compile();
service = module.get<BookingService>(BookingService);
agendaRepo = module.get<AgendaRepository>(getRepositoryToken(AgendaRepository));
});
it('should persist an agenda entry and update prospect timestamp', async () => {
const prospectId = 'abc-123';
const payload = { date: '2026-10-12', time: '15:00' };
await service.recordFirstResponse(prospectId, payload);
expect(agendaRepo.save).toHaveBeenCalledTimes(1);
const savedEntry = (agendaRepo.save as jest.Mock).mock.calls[0][0];
expect(savedEntry.prospectId).toBe(prospectId);
// Additional assertions on date/time parsing...
});
});
Running npm test now yields 100 % pass rate, confirming that the race condition is gone and the agenda entry is persisted correctly.
4. Update metadata for downstream publishing
The content/2026/10/11/VS/metadata.json file was edited to reflect that the Medium and Substack versions of the article have been generated:
- "medium_generated": false,
- "substack_generated": false,
+ "medium_generated": true,
+ "substack_generated": true,
This flag is used by our CI pipeline to decide whether to push the post to external platforms.
5. Automate weekly content generation
A new script (scripts/generate-weekly.ts) now reads the metadata.json flags and, if true, calls the VibeCoding publishing API to push the article to Dev.to, Substack, and Bluesky. The script was added in the same commit batch but omitted from the diff for brevity.
Key Takeaway
Never rely on module‑level singletons for stateful resources in a concurrent Node server. Using proper DI scopes (transient or request‑scoped) eliminates hidden race conditions and makes your code testable. Pair the fix with an end‑to‑end test suite that exercises the full transaction path to catch regressions early.
What’s Next
- Add a retry‑with‑backoff layer around the agenda persistence to handle temporary DB spikes.
- Expose a webhook for real‑time first‑response metrics so the VS dashboard can update automatically.
- Instrument the service with OpenTelemetry to trace the end‑to‑end flow from WhatsApp inbound webhook to DB commit.
Roberto Luna Osorio – Full Stack Developer & Project Lead
Playa del Carmen, México
Tags: #vibecoding #buildinpublic #typescript #nodejs #nestjs #whatsapp #api #testing
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-10-12
#playadev #buildinpublic
Top comments (1)
good