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 <noreply@paperclip.ing>
This commit is contained in:
Paperclip
2026-04-12 23:24:56 +00:00
parent dd0a57dcf9
commit c1e8d9830d
+70 -29
View File
@@ -18,9 +18,10 @@ import {
buildReminderEmail, buildReminderEmail,
sendEmail, sendEmail,
} from "./email.js"; } from "./email.js";
import { smsSend } from "./sms.js";
// How many hours before the appointment to send each reminder. // TCPA-required opt-out text appended to every SMS reminder
// Override via env: REMINDER_HOURS_EARLY (default 24) and REMINDER_HOURS_LATE (default 2). const TCPA_OPT_OUT = "Reply STOP to opt out. Msg & data rates may apply.";
function getReminderWindows(): { label: string; hours: number }[] { function getReminderWindows(): { label: string; hours: number }[] {
const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24); const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24);
const late = Number(process.env.REMINDER_HOURS_LATE ?? 2); const late = Number(process.env.REMINDER_HOURS_LATE ?? 2);
@@ -65,23 +66,39 @@ export async function runReminderCheck(): Promise<void> {
); );
for (const appt of upcoming) { for (const appt of upcoming) {
// Check if reminder already sent (unique constraint prevents double-send) const [emailLog] = await db
const existing = await db
.select({ id: reminderLogs.id }) .select({ id: reminderLogs.id })
.from(reminderLogs) .from(reminderLogs)
.where( .where(
and( and(
eq(reminderLogs.appointmentId, appt.id), eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label) eq(reminderLogs.reminderType, window.label),
eq(reminderLogs.channel, "email")
) )
) )
.limit(1); .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 // Fetch related records for the email
const [client] = await db 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) .from(clients)
.where(eq(clients.id, appt.clientId)) .where(eq(clients.id, appt.clientId))
.limit(1); .limit(1);
@@ -112,8 +129,6 @@ export async function runReminderCheck(): Promise<void> {
if (!pet || !service) continue; 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; let confirmationToken = appt.confirmationToken;
if (!confirmationToken) { if (!confirmationToken) {
confirmationToken = randomBytes(32).toString("hex"); confirmationToken = randomBytes(32).toString("hex");
@@ -123,27 +138,53 @@ export async function runReminderCheck(): Promise<void> {
.where(eq(appointments.id, appt.id)); .where(eq(appointments.id, appt.id));
} }
const sent = await sendEmail( if (!emailLog) {
buildReminderEmail( const sent = await sendEmail(
client.email, buildReminderEmail(
{ client.email,
clientName: client.name, {
petName: pet.name, clientName: client.name,
serviceName: service.name, petName: pet.name,
groomerName, serviceName: service.name,
startTime: appt.startTime, groomerName,
}, startTime: appt.startTime,
window.hours, },
confirmationToken window.hours,
) confirmationToken
); )
);
if (sent) { if (sent) {
// Record send — ignore conflicts (race condition between instances) await db
await db .insert(reminderLogs)
.insert(reminderLogs) .values({ appointmentId: appt.id, reminderType: window.label, channel: "email" })
.values({ appointmentId: appt.id, reminderType: window.label }) .onConflictDoNothing();
.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);
}
} }
} }
} }