feat(GRO-607): Stripe Elements payment UI replacing mock flow #274

Closed
the-dogfather-cto[bot] wants to merge 10 commits from feature/gro-597-payment-ui into main
Showing only changes of commit c1e8d9830d - Show all commits
+52 -11
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,6 +138,7 @@ export async function runReminderCheck(): Promise<void> {
.where(eq(appointments.id, appt.id)); .where(eq(appointments.id, appt.id));
} }
if (!emailLog) {
const sent = await sendEmail( const sent = await sendEmail(
buildReminderEmail( buildReminderEmail(
client.email, client.email,
@@ -139,13 +155,38 @@ export async function runReminderCheck(): Promise<void> {
); );
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 }) .values({ appointmentId: appt.id, reminderType: window.label, channel: "email" })
.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);
}
}
}
} }
} }