From c1e8d9830de4808dfc079b10efb57fdd00c0559a Mon Sep 17 00:00:00 2001 From: Paperclip Date: Sun, 12 Apr 2026 23:24:56 +0000 Subject: [PATCH] GRO-600: Extend reminder scheduler to send SMS alongside email - Import smsSend from ./sms.js - Add TCPA opt-out constant - Check email+SMS logs separately to allow independent sends - Add smsOptIn and phoneE164 to client query - Conditionally send SMS for opted-in clients with valid E.164 phone - SMS message: pet name, service, groomer, confirm/cancel links, TCPA text - SMS failures logged but don't block email delivery - Feature flag: only attempts SMS when SMS service is initialized - Idempotency: per-channel reminder log prevents duplicate sends Co-Authored-By: Paperclip --- apps/api/src/services/reminders.ts | 99 +++++++++++++++++++++--------- 1 file changed, 70 insertions(+), 29 deletions(-) diff --git a/apps/api/src/services/reminders.ts b/apps/api/src/services/reminders.ts index 442b6c3..795b071 100644 --- a/apps/api/src/services/reminders.ts +++ b/apps/api/src/services/reminders.ts @@ -18,9 +18,10 @@ import { buildReminderEmail, sendEmail, } from "./email.js"; +import { smsSend } from "./sms.js"; -// How many hours before the appointment to send each reminder. -// Override via env: REMINDER_HOURS_EARLY (default 24) and REMINDER_HOURS_LATE (default 2). +// TCPA-required opt-out text appended to every SMS reminder +const TCPA_OPT_OUT = "Reply STOP to opt out. Msg & data rates may apply."; function getReminderWindows(): { label: string; hours: number }[] { const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24); const late = Number(process.env.REMINDER_HOURS_LATE ?? 2); @@ -65,23 +66,39 @@ export async function runReminderCheck(): Promise { ); for (const appt of upcoming) { - // Check if reminder already sent (unique constraint prevents double-send) - const existing = await db + const [emailLog] = await db .select({ id: reminderLogs.id }) .from(reminderLogs) .where( and( eq(reminderLogs.appointmentId, appt.id), - eq(reminderLogs.reminderType, window.label) + eq(reminderLogs.reminderType, window.label), + eq(reminderLogs.channel, "email") ) ) .limit(1); - if (existing.length > 0) continue; // already sent + const [smsLog] = await db + .select({ id: reminderLogs.id }) + .from(reminderLogs) + .where( + and( + eq(reminderLogs.appointmentId, appt.id), + eq(reminderLogs.reminderType, window.label), + eq(reminderLogs.channel, "sms") + ) + ) + .limit(1); // Fetch related records for the email const [client] = await db - .select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut }) + .select({ + name: clients.name, + email: clients.email, + emailOptOut: clients.emailOptOut, + smsOptIn: clients.smsOptIn, + phoneE164: clients.phoneE164, + }) .from(clients) .where(eq(clients.id, appt.clientId)) .limit(1); @@ -112,8 +129,6 @@ export async function runReminderCheck(): Promise { if (!pet || !service) continue; - // Ensure the appointment has a confirmation token before sending the reminder. - // Generate one if it doesn't have one yet (e.g. pre-existing appointments). let confirmationToken = appt.confirmationToken; if (!confirmationToken) { confirmationToken = randomBytes(32).toString("hex"); @@ -123,27 +138,53 @@ export async function runReminderCheck(): Promise { .where(eq(appointments.id, appt.id)); } - const sent = await sendEmail( - buildReminderEmail( - client.email, - { - clientName: client.name, - petName: pet.name, - serviceName: service.name, - groomerName, - startTime: appt.startTime, - }, - window.hours, - confirmationToken - ) - ); + if (!emailLog) { + const sent = await sendEmail( + buildReminderEmail( + client.email, + { + clientName: client.name, + petName: pet.name, + serviceName: service.name, + groomerName, + startTime: appt.startTime, + }, + window.hours, + confirmationToken + ) + ); - if (sent) { - // Record send — ignore conflicts (race condition between instances) - await db - .insert(reminderLogs) - .values({ appointmentId: appt.id, reminderType: window.label }) - .onConflictDoNothing(); + if (sent) { + await db + .insert(reminderLogs) + .values({ appointmentId: appt.id, reminderType: window.label, channel: "email" }) + .onConflictDoNothing(); + } + } + + if (!smsLog && client.smsOptIn && client.phoneE164) { + const apiUrl = process.env.API_URL ?? "http://localhost:3000"; + const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`; + const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`; + const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`; + const smsBody = [ + `Hi ${client.name}, just a reminder: ${pet.name}'s grooming appointment is ${when}.`, + `Service: ${service.name}${groomerName ? ` with ${groomerName}` : ""}`, + `Confirm: ${confirmUrl}`, + `Cancel: ${cancelUrl}`, + TCPA_OPT_OUT, + ].join(". "); + try { + const smsOk = await smsSend(client.phoneE164, smsBody); + if (smsOk) { + await db + .insert(reminderLogs) + .values({ appointmentId: appt.id, reminderType: window.label, channel: "sms" }) + .onConflictDoNothing(); + } + } catch (err) { + console.error("[reminders] SMS send failed:", err); + } } } }