/opt/mawid/apps/api/src/waitlist
NameSizeModeActions
waitlist-queue.ts3630644editdlrm
waitlist-reply.service.ts8050644editdlrm
waitlist.e2e.spec.ts140810644editdlrm
waitlist.module.ts10560644editdlrm
waitlist.service.ts132590644editdlrm
waitlist.worker.ts11970644editdlrm
Edit: /opt/mawid/apps/api/src/waitlist/waitlist.service.ts (13259B)
import { Inject, Injectable, Logger, UnprocessableEntityException } from '@nestjs/common'; import { t, type Language } from '@mawid/shared'; import { formatLocal, utcToWallTime } from '../agent/tz'; import { PrismaService } from '../prisma/prisma.service'; import { ReminderService } from '../reminders/reminder.service'; import { OwnerNotifierService } from '../whatsapp/owner-notifier.service'; import { WaSenderService } from '../whatsapp/wa-sender.service'; import { WAITLIST_QUEUE, holdExpiryJobId, type WaitlistQueue } from './waitlist-queue'; export interface FreedSlot { clinicId: string; staffId: string; serviceId: string; startsAt: Date; endsAt: Date; /** The patient who freed the slot — never offered their own slot back. */ excludePatientId?: string; } const MIN_LEAD_TIME_MS = 3 * 3600_000; // refill only when cancelled ≥3h before start const DEFAULT_HOLD_MINUTES = 20; /** * Waitlist auto-refill (PROJECT_PLAN Phase 5): freed slots are offered to * matching waitlist candidates one at a time with a timed hold, cascading on * decline/expiry. Accepting books atomically (per-staff advisory lock). */ @Injectable() export class WaitlistService { private readonly logger = new Logger(WaitlistService.name); constructor( private readonly prisma: PrismaService, private readonly sender: WaSenderService, private readonly reminders: ReminderService, private readonly ownerNotifier: OwnerNotifierService, @Inject(WAITLIST_QUEUE) private readonly queue: WaitlistQueue, ) {} /** Entry point: called after an appointment transitions to cancelled. */ async onAppointmentCancelled(appointmentId: string): Promise { const appointment = await this.prisma.appointment.findUnique({ where: { id: appointmentId } }); if (!appointment || appointment.status !== 'cancelled') return; if (appointment.startsAt.getTime() - Date.now() < MIN_LEAD_TIME_MS) return; await this.offerSlot({ clinicId: appointment.clinicId, staffId: appointment.staffId, serviceId: appointment.serviceId, startsAt: appointment.startsAt, endsAt: appointment.endsAt, excludePatientId: appointment.patientId, }); } /** Offers the slot to the best-ranked candidate not yet offered this slot. */ async offerSlot(slot: FreedSlot): Promise { // Slot may have been rebooked (agent/dashboard) while the cascade ran. const taken = await this.prisma.appointment.findFirst({ where: { staffId: slot.staffId, status: { in: ['pending', 'confirmed'] }, startsAt: { lt: slot.endsAt }, endsAt: { gt: slot.startsAt }, }, }); if (taken) return; // Another live hold on this slot means an offer is already out. const liveHold = await this.prisma.slotHold.findFirst({ where: { staffId: slot.staffId, startsAt: slot.startsAt, status: 'offered', expiresAt: { gt: new Date() }, }, }); if (liveHold) return; const clinic = await this.prisma.clinic.findUniqueOrThrow({ where: { id: slot.clinicId } }); const holdMinutes = ((clinic.settings ?? {}) as { waitlistHoldMinutes?: number }).waitlistHoldMinutes ?? DEFAULT_HOLD_MINUTES; // Everyone who already had a hold on this exact slot is out of the cascade. const priorHolds = await this.prisma.slotHold.findMany({ where: { clinicId: slot.clinicId, staffId: slot.staffId, startsAt: slot.startsAt }, select: { waitlistEntryId: true }, }); const candidates = await this.prisma.waitlistEntry.findMany({ where: { clinicId: slot.clinicId, serviceId: slot.serviceId, status: { in: ['active', 'notified'] }, id: { notIn: priorHolds.map((h) => h.waitlistEntryId) }, ...(slot.excludePatientId ? { patientId: { not: slot.excludePatientId } } : {}), }, include: { patient: true }, orderBy: { createdAt: 'asc' }, }); const candidate = candidates.find((entry) => this.windowMatches( clinic.timezone, slot.startsAt, (entry.preferredWindow ?? {}) as { fromDate?: string; toDate?: string }, ), ); if (!candidate) { this.logger.log(`no waitlist candidate for freed slot ${slot.staffId}@${slot.startsAt.toISOString()}`); return; } const hold = await this.prisma.slotHold.create({ data: { clinicId: slot.clinicId, waitlistEntryId: candidate.id, staffId: slot.staffId, serviceId: slot.serviceId, startsAt: slot.startsAt, endsAt: slot.endsAt, expiresAt: new Date(Date.now() + holdMinutes * 60_000), }, }); await this.prisma.waitlistEntry.update({ where: { id: candidate.id }, data: { status: 'notified' }, }); await this.sendOffer(clinic, candidate.patient, hold.id, slot, holdMinutes); await this.queue.add( 'expire', { holdId: hold.id }, { jobId: holdExpiryJobId(hold.id), delay: holdMinutes * 60_000 }, ); await this.audit(slot.clinicId, 'waitlist:offered', { holdId: hold.id, waitlistEntryId: candidate.id, }); } /** Patient accepted — book atomically; on race loss, apologize and stop. */ async acceptHold(clinicId: string, patientId: string, holdId: string): Promise { const hold = await this.prisma.slotHold.findFirst({ where: { id: holdId, clinicId, status: 'offered' }, include: { waitlistEntry: { include: { patient: true } } }, }); if (!hold || hold.waitlistEntry.patientId !== patientId) return false; const clinic = await this.prisma.clinic.findUniqueOrThrow({ where: { id: clinicId } }); const patient = hold.waitlistEntry.patient; const lang = this.lang(patient.language); if (hold.expiresAt < new Date()) { await this.prisma.slotHold.update({ where: { id: hold.id }, data: { status: 'expired' } }); await this.sender.sendText(clinicId, patientId, t('waitlistGone', lang)); return true; } const appointment = await this.prisma.$transaction(async (tx) => { await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${hold.staffId}))`; const conflict = await tx.appointment.findFirst({ where: { staffId: hold.staffId, status: { in: ['pending', 'confirmed'] }, startsAt: { lt: hold.endsAt }, endsAt: { gt: hold.startsAt }, }, }); if (conflict) return null; const created = await tx.appointment.create({ data: { clinicId, patientId, staffId: hold.staffId, serviceId: hold.serviceId, startsAt: hold.startsAt, endsAt: hold.endsAt, status: 'confirmed', // accepting the offer IS the confirmation source: 'whatsapp', }, }); await tx.slotHold.update({ where: { id: hold.id }, data: { status: 'accepted' } }); await tx.waitlistEntry.update({ where: { id: hold.waitlistEntryId }, data: { status: 'fulfilled' }, }); return created; }); await this.removeExpiryJob(hold.id); if (!appointment) { await this.prisma.slotHold.update({ where: { id: hold.id }, data: { status: 'cancelled' } }); await this.prisma.waitlistEntry.update({ where: { id: hold.waitlistEntryId }, data: { status: 'active' }, }); await this.sender.sendText(clinicId, patientId, t('waitlistGone', lang)); await this.audit(clinicId, 'waitlist:accept_lost_race', { holdId: hold.id }); return true; } await this.reminders.scheduleForAppointment(appointment.id); const time = formatLocal(clinic.timezone, appointment.startsAt); await this.sender.sendText(clinicId, patientId, t('waitlistBooked', lang, { time })); const service = await this.prisma.service.findUnique({ where: { id: hold.serviceId } }); const serviceName = ((service?.name ?? {}) as Record)[clinic.defaultLanguage] ?? 'appointment'; await this.ownerNotifier.sendText( clinicId, t('slotRecoveredOwner', this.lang(clinic.defaultLanguage), { patient: patient.name ?? patient.waPhone, service: serviceName, time, }), ); await this.audit(clinicId, 'waitlist:refilled', { holdId: hold.id, appointmentId: appointment.id, }); return true; } /** Patient declined — release and cascade to the next candidate. */ async declineHold(clinicId: string, patientId: string, holdId: string): Promise { const hold = await this.prisma.slotHold.findFirst({ where: { id: holdId, clinicId, status: 'offered' }, include: { waitlistEntry: { include: { patient: true } } }, }); if (!hold || hold.waitlistEntry.patientId !== patientId) return false; await this.prisma.slotHold.update({ where: { id: hold.id }, data: { status: 'declined' } }); await this.prisma.waitlistEntry.update({ where: { id: hold.waitlistEntryId }, data: { status: 'active' }, }); await this.removeExpiryJob(hold.id); await this.sender.sendText( clinicId, patientId, t('waitlistDeclineAck', this.lang(hold.waitlistEntry.patient.language)), ); await this.audit(clinicId, 'waitlist:declined', { holdId: hold.id }); await this.offerSlot({ clinicId, staffId: hold.staffId, serviceId: hold.serviceId, startsAt: hold.startsAt, endsAt: hold.endsAt, }); return true; } /** Hold timed out without a reply — release and cascade. */ async expireHold(holdId: string): Promise { const hold = await this.prisma.slotHold.findFirst({ where: { id: holdId, status: 'offered' }, }); if (!hold) return; await this.prisma.slotHold.update({ where: { id: hold.id }, data: { status: 'expired' } }); await this.prisma.waitlistEntry.update({ where: { id: hold.waitlistEntryId }, data: { status: 'active' }, }); await this.audit(hold.clinicId, 'waitlist:offer_expired', { holdId: hold.id }); await this.offerSlot({ clinicId: hold.clinicId, staffId: hold.staffId, serviceId: hold.serviceId, startsAt: hold.startsAt, endsAt: hold.endsAt, }); } private async sendOffer( clinic: { id: string; timezone: string }, patient: { id: string; language: string }, holdId: string, slot: FreedSlot, holdMinutes: number, ): Promise { const service = await this.prisma.service.findUnique({ where: { id: slot.serviceId } }); const lang = this.lang(patient.language); const serviceName = ((service?.name ?? {}) as Record)[lang] ?? 'appointment'; const time = formatLocal(clinic.timezone, slot.startsAt); const text = t('waitlistOffer', lang, { service: serviceName, time, minutes: String(holdMinutes), }); const buttons = [ { id: `wl_yes:${holdId}`, title: t('waitlistAccept', lang) }, { id: `wl_no:${holdId}`, title: t('waitlistDecline', lang) }, ]; try { await this.sender.sendButtons(clinic.id, patient.id, text, buttons); } catch (err) { if (!(err instanceof UnprocessableEntityException)) throw err; // 24h window closed → template with the same quick-reply payloads. // Template "waitlist_offer" must be approved in Meta (§7.6). await this.sender.sendTemplate( clinic.id, patient.id, 'waitlist_offer', lang, [ { type: 'body', parameters: [ { type: 'text', text: serviceName }, { type: 'text', text: time }, { type: 'text', text: String(holdMinutes) }, ], }, ...['wl_yes', 'wl_no'].map((action, index) => ({ type: 'button', sub_type: 'quick_reply', index, parameters: [{ type: 'payload', payload: `${action}:${holdId}` }], })), ], text, ); } } private windowMatches( timezone: string, startsAt: Date, window: { fromDate?: string; toDate?: string }, ): boolean { if (!window.fromDate && !window.toDate) return true; const local = utcToWallTime(timezone, startsAt); const localDate = `${local.year}-${String(local.month).padStart(2, '0')}-${String(local.day).padStart(2, '0')}`; if (window.fromDate && localDate < window.fromDate) return false; if (window.toDate && localDate > window.toDate) return false; return true; } private async removeExpiryJob(holdId: string): Promise { try { const job = await this.queue.getJob(holdExpiryJobId(holdId)); await job?.remove(); } catch (err) { this.logger.warn(`could not remove hold expiry job for ${holdId}: ${String(err)}`); } } private lang(language: string): Language { return (['ar', 'tr', 'en'] as const).includes(language as Language) ? (language as Language) : 'en'; } private async audit(clinicId: string, action: string, meta: object): Promise { await this.prisma.auditLog.create({ data: { clinicId, actor: 'system', action, meta: meta as object }, }); } }