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:
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user