/opt/mawid/apps/api/src/reminders
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
}
}
}