/** * Reminder jobs: planned from the engine whenever a booking is confirmed or moved, * sent by the worker. Retries with backoff; never sends for cancelled bookings. */ import type { Booking as BookingRow } from "@prisma/client"; import prisma from "../db.server"; import { planReminders } from "../engine/customer"; import type { Booking, Method } from "../engine/types"; import { loadShopConfig } from "./config.server"; import { NotConfigured, send } from "./notifications.server"; import { features } from "./plans"; export function rowToBooking(b: BookingRow): Booking { return { id: b.id, orderId: b.orderId ?? undefined, orderName: b.orderName ?? undefined, status: b.status as Booking["status"], method: b.method as Method, locationId: b.locationId ?? "", date: b.date ?? "", slotStart: b.slotStart ?? undefined, slotEnd: b.slotEnd ?? undefined, timezone: b.timezone, customer: { name: b.customerName ?? undefined, email: b.customerEmail ?? undefined, phone: b.customerPhone ?? undefined }, address: (b.address as Booking["address"]) ?? undefined, items: (b.items as Booking["items"]) ?? undefined, rescheduleCount: b.rescheduleCount, }; } const fmtTime = (t: string) => { const [h, m] = t.split(":").map(Number); return `${h % 12 || 12}${m ? `:${String(m).padStart(2, "0")}` : ""} ${h >= 12 ? "PM" : "AM"}`; }; export async function scheduleReminders(bookingId: string, now = Date.now()) { const row = await prisma.booking.findUniqueOrThrow({ where: { id: bookingId }, include: { shop: true } }); await prisma.reminderJob.updateMany({ where: { bookingId, status: "pending" }, data: { status: "cancelled" } }); if (row.status !== "confirmed" || !row.date) return 0; const cfg = await loadShopConfig(row.shop.domain); const f = features(cfg.shop.plan); const b = rowToBooking(row); const loc = cfg.locations.find((l) => l.id === b.locationId); const policies = cfg.settings.reminders.filter((p) => p.channel === "email" || f.smsWhatsapp); const planned = planReminders(b, policies, now, { date: new Date(`${b.date}T12:00:00Z`).toLocaleDateString("en-CA", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" }), time: b.slotStart && b.slotEnd ? `${fmtTime(b.slotStart)}–${fmtTime(b.slotEnd)}` : "", method: cfg.settings.widget.labels[b.method].toLowerCase(), location: loc?.name ?? "", }); if (planned.length) await prisma.reminderJob.createMany({ data: planned.map((p) => ({ shopId: row.shopId, bookingId, policyId: p.policyId, channel: p.channel, sendAt: new Date(p.sendAt), body: p.body })), }); return planned.length; } export async function runDueReminders(now = new Date(), batch = 100) { const due = await prisma.reminderJob.findMany({ where: { status: "pending", sendAt: { lte: now }, attempts: { lt: 5 } }, include: { booking: true, shop: true }, take: batch, orderBy: { sendAt: "asc" }, }); let sent = 0; for (const j of due) { // Claim the job so parallel workers don't double-send. const claimed = await prisma.reminderJob.updateMany({ where: { id: j.id, status: "pending" }, data: { status: "sending", attempts: { increment: 1 } } }); if (!claimed.count) continue; if (j.booking.status !== "confirmed") { await prisma.reminderJob.update({ where: { id: j.id }, data: { status: "cancelled" } }); continue; } const to = j.channel === "email" ? j.booking.customerEmail : j.booking.customerPhone; try { if (!to) throw new NotConfigured("No contact for channel"); const cfg = await loadShopConfig(j.shop.domain); await send(cfg.settings, j.shop.domain.replace(".myshopify.com", ""), { channel: j.channel as "email", to, body: j.body, subject: `Reminder: order ${j.booking.orderName ?? ""}` }); await prisma.reminderJob.update({ where: { id: j.id }, data: { status: "sent", sentAt: new Date() } }); sent++; } catch (e) { const permanent = e instanceof NotConfigured || j.attempts + 1 >= 5; await prisma.reminderJob.update({ where: { id: j.id }, data: { status: permanent ? "failed" : "pending", lastError: String((e as Error).message).slice(0, 500), sendAt: permanent ? undefined : new Date(now.getTime() + 2 ** j.attempts * 60_000), }, }); } } return sent; }