DEV Community

Roberto Luna
Roberto Luna

Posted on

Building a Reliable WhatsApp‑Powered First‑Response Scheduler in a Node/TypeScript API

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

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

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

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

The provider registration in app.module.ts:

// src/app.module.ts
@Module({
  providers: [
    {
      provide: 'AgendaRepository',
      useClass: AgendaRepository, // default transient scope
    },
    BookingService,
  ],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

  1. Add a retry‑with‑backoff layer around the agenda persistence to handle temporary DB spikes.
  2. Expose a webhook for real‑time first‑response metrics so the VS dashboard can update automatically.
  3. 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)

Collapse
 
dalbaeb123321 profile image
Dalbaeb •

good