/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.worker.ts (4060B)
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Worker, type Job } from 'bullmq'; import IORedis from 'ioredis'; import { PrismaService } from '../prisma/prisma.service'; import { REMINDER_QUEUE_NAME, type ReminderJobName, type ReminderQueueJob } from './reminder-queue'; import { ReminderSenderService } from './reminder-sender.service'; @Injectable() export class ReminderWorker implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(ReminderWorker.name); private worker?: Worker; constructor( private readonly prisma: PrismaService, private readonly config: ConfigService, private readonly reminderSender: ReminderSenderService, ) {} onModuleInit() { const connection = new IORedis(this.config.getOrThrow('REDIS_URL'), { maxRetriesPerRequest: null, }); this.worker = new Worker( REMINDER_QUEUE_NAME, (job) => this.dispatch(job), { connection, concurrency: 5 }, ); this.worker.on('failed', (job, err) => { this.logger.warn(`${job?.name} ${job?.data.id} failed: ${err.message}`); }); } async onModuleDestroy() { await this.worker?.close(); } dispatch(job: Job): Promise { return job.name === 'risk-check' ? this.handleRiskCheck(job.data.id) : this.handleReminder(job.data.id); } /** Fires one reminder. Status guards make replays and races no-ops. */ async handleReminder(reminderJobId: string): Promise { const row = await this.prisma.reminderJob.findUnique({ where: { id: reminderJobId }, include: { appointment: { include: { clinic: true, patient: true, service: true } }, }, }); if (!row || row.status !== 'pending') return; const appointment = row.appointment; if (!['pending', 'confirmed'].includes(appointment.status) || appointment.startsAt < new Date()) { await this.prisma.reminderJob.update({ where: { id: row.id }, data: { status: 'cancelled' }, }); return; } // First reminder asks to confirm; the final one nudges hard if the patient // never replied (and flags the appointment), or stays friendly if confirmed. const tone = row.kind === 'h24' ? 'first' : appointment.status === 'pending' ? 'nudge' : 'final'; try { await this.reminderSender.send( { appointment, clinic: appointment.clinic, patient: appointment.patient, service: appointment.service, }, tone, ); if (tone === 'nudge' && !appointment.unconfirmedRisk) { await this.prisma.appointment.update({ where: { id: appointment.id }, data: { unconfirmedRisk: true }, }); } await this.prisma.reminderJob.update({ where: { id: row.id }, data: { status: 'sent' } }); } catch (err) { await this.prisma.reminderJob.update({ where: { id: row.id }, data: { status: 'failed' } }); throw err; } } /** * T-4h escalation: patient never responded to the first reminder → flag the * appointment for the dashboard and fire the final reminder early as a * stronger nudge (consuming the pending h2 job so it doesn't send twice). */ async handleRiskCheck(appointmentId: string): Promise { const appointment = await this.prisma.appointment.findUnique({ where: { id: appointmentId } }); if (!appointment || appointment.status !== 'pending') return; await this.prisma.appointment.update({ where: { id: appointmentId }, data: { unconfirmedRisk: true }, }); const pendingFinal = await this.prisma.reminderJob.findFirst({ where: { appointmentId, kind: 'h2', status: 'pending' }, }); if (pendingFinal) { await this.handleReminder(pendingFinal.id); // marks it sent — delayed job becomes a no-op } } }