/opt/mawid/apps/api/src/reminders
Edit: /opt/mawid/apps/api/src/reminders/reminder-reply.service.ts (3433B)
import { Injectable } from '@nestjs/common';
import { t, type Language } from '@mawid/shared';
import { PrismaService } from '../prisma/prisma.service';
import { WaSenderService } from '../whatsapp/wa-sender.service';
import { ReminderService } from './reminder.service';
const BUTTON_RE = /^(confirm|cancel|resched):([a-z0-9]+)$/i;
export interface ReminderReplyResult {
handled: boolean;
/** Set when a cancel button freed a slot — caller triggers waitlist refill. */
cancelledAppointmentId?: string;
}
/**
* Handles reminder button replies (PROJECT_PLAN Phase 4 task 3) before the
* agent sees the message: confirm → status confirmed; cancel → cancelled +
* job cleanup (caller triggers the Phase 5 waitlist refill); reschedule →
* deterministic prompt, then the patient's reply flows to the agent.
*/
@Injectable()
export class ReminderReplyService {
constructor(
private readonly prisma: PrismaService,
private readonly sender: WaSenderService,
private readonly reminders: ReminderService,
) {}
/** Reports whether the message was a reminder button, and any freed slot. */
async tryHandle(clinicId: string, patientId: string, body: string): Promise
{
const match = BUTTON_RE.exec(body.trim());
if (!match) return { handled: false };
const [, action, appointmentId] = match;
const appointment = await this.prisma.appointment.findFirst({
where: {
id: appointmentId,
clinicId,
patientId,
status: { in: ['pending', 'confirmed'] },
},
include: { patient: true },
});
if (!appointment) return { handled: false }; // stale button — let the agent respond
const lang = this.lang(appointment.patient.language);
switch (action.toLowerCase()) {
case 'confirm': {
if (appointment.status === 'pending') {
await this.prisma.appointment.update({
where: { id: appointment.id },
data: { status: 'confirmed', unconfirmedRisk: false },
});
}
await this.audit(clinicId, 'reminder:confirm', appointment.id);
await this.sender.sendText(clinicId, patientId, t('confirmedThanks', lang));
return { handled: true };
}
case 'cancel': {
await this.prisma.appointment.update({
where: { id: appointment.id },
data: { status: 'cancelled', cancelReason: 'reminder_button' },
});
await this.reminders.cancelForAppointment(appointment.id);
await this.audit(clinicId, 'reminder:cancel', appointment.id);
await this.sender.sendText(clinicId, patientId, t('cancelledOk', lang));
return { handled: true, cancelledAppointmentId: appointment.id };
}
case 'resched': {
await this.audit(clinicId, 'reminder:reschedule_requested', appointment.id);
await this.sender.sendText(clinicId, patientId, t('reschedulePrompt', lang));
return { handled: true };
}
default:
return { handled: false };
}
}
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, appointmentId: string): Promise {
await this.prisma.auditLog.create({
data: { clinicId, actor: 'patient', action, meta: { appointmentId } },
});
}
}