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
+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);
}
} }
} }
} }