/opt/mawid/apps/api/src/waitlist
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 },
});
}
}