datewise/app/services/reminders.server.ts
metatroncubeswdev e56182e0e3 chore: initial Datewise app (rebranded from SlotSure prototype)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:06:49 -04:00

96 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
}