/opt/mawid/apps/api/src/reminders
NameSizeModeActions
reminder-queue.ts5270644editdlrm
reminder-reply.service.ts34330644editdlrm
reminder-sender.service.ts21330644editdlrm
reminder.service.ts35900644editdlrm
reminder.worker.ts40600644editdlrm
reminders.e2e.spec.ts133040644editdlrm
reminders.module.ts10880644editdlrm
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)}`); } } }