96 lines
4.3 KiB
TypeScript
96 lines
4.3 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|