/opt/mawid/apps/api/src/reminders
Edit: /opt/mawid/apps/api/src/reminders/reminder.service.ts (3590B)
import { Inject, Injectable, Logger } from '@nestjs/common';
import type { ReminderKind } from '@mawid/db';
import { PrismaService } from '../prisma/prisma.service';
import { REMINDER_QUEUE, riskCheckJobId, type ReminderQueue } from './reminder-queue';
const RISK_CHECK_OFFSET_HOURS = 4;
const DEFAULT_OFFSETS_HOURS = [24, 2];
/**
* Schedules/cancels reminder jobs for appointments (PROJECT_PLAN Phase 4).
* Rebuild-safe: scheduleForAppointment always cancels pending jobs first, and
* BullMQ job ids are deterministic, so repeated calls are idempotent.
*/
@Injectable()
export class ReminderService {
private readonly logger = new Logger(ReminderService.name);
constructor(
private readonly prisma: PrismaService,
@Inject(REMINDER_QUEUE) private readonly queue: ReminderQueue,
) {}
async scheduleForAppointment(appointmentId: string): Promise
{
const appointment = await this.prisma.appointment.findUnique({
where: { id: appointmentId },
include: { clinic: true },
});
if (!appointment) return;
await this.cancelForAppointment(appointmentId);
if (!['pending', 'confirmed'].includes(appointment.status)) return;
const settings = (appointment.clinic.settings ?? {}) as {
reminderOffsetsHours?: number[];
};
const offsets = [...(settings.reminderOffsetsHours ?? DEFAULT_OFFSETS_HOURS)]
.filter((h) => h > 0)
.sort((a, b) => b - a);
if (offsets.length === 0) return;
// Largest offset = first reminder (kind h24), smallest = final nudge (h2).
const planned: Array<{ kind: ReminderKind; offsetHours: number }> = [
{ kind: 'h24', offsetHours: offsets[0] },
];
if (offsets.length > 1) planned.push({ kind: 'h2', offsetHours: offsets[offsets.length - 1] });
const now = Date.now();
for (const { kind, offsetHours } of planned) {
const scheduledFor = new Date(appointment.startsAt.getTime() - offsetHours * 3600_000);
if (scheduledFor.getTime() <= now) continue; // appointment is closer than the offset
const row = await this.prisma.reminderJob.create({
data: { appointmentId, kind, scheduledFor },
});
await this.queue.add(
'reminder',
{ id: row.id },
{ jobId: row.id, delay: scheduledFor.getTime() - now },
);
}
// T-4h: if the patient never responded, escalate (stronger nudge + risk flag).
const riskAt = appointment.startsAt.getTime() - RISK_CHECK_OFFSET_HOURS * 3600_000;
if (riskAt > now) {
await this.queue.add(
'risk-check',
{ id: appointmentId },
{ jobId: riskCheckJobId(appointmentId), delay: riskAt - now },
);
}
}
/** Marks pending rows cancelled and removes their queued BullMQ jobs. */
async cancelForAppointment(appointmentId: string): Promise {
const rows = await this.prisma.reminderJob.findMany({
where: { appointmentId, status: 'pending' },
});
for (const row of rows) {
await this.prisma.reminderJob.update({
where: { id: row.id },
data: { status: 'cancelled' },
});
await this.removeQueueJob(row.id);
}
await this.removeQueueJob(riskCheckJobId(appointmentId));
}
private async removeQueueJob(jobId: string): Promise {
try {
const job = await this.queue.getJob(jobId);
await job?.remove();
} catch (err) {
// A job mid-execution cannot be removed — the worker's status guards
// make that safe; log and move on.
this.logger.warn(`could not remove queue job ${jobId}: ${String(err)}`);
}
}
}