datewise/app/services/orders.server.ts
metatroncubeswdev ee8056efc2 fix: install race, silent Admin API errors, widget width; pickup setup hint
- ensureShop: concurrent installs reuse the row created by the other request (P2002)
- order tagging and booking metafield log Shopify userErrors instead of failing silently
- widget keeps a readable 760px width in full-width theme sections
- setup guide links to Shopify's pickup settings so checkout offers pickup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:15:36 -04:00

163 lines
8.4 KiB
TypeScript

/**
* Order webhooks → bookings.
*
* orders/create confirm the hold (idempotent), verify the fee that was paid,
* tag the order and write a booking metafield, plan reminders.
* orders/cancelled release capacity (per policy) and offer it to the waitlist.
* orders/fulfilled mark fulfilled.
*
* Orders that reach us without a selection (Buy now, accelerated checkouts that
* skipped the cart, draft orders) go to the review queue when scheduling is required.
*/
import prisma from "../db.server";
import { capsFor } from "../engine/capacity";
import { evaluateRules } from "../engine/rules";
import { zonedToUtc } from "../engine/time";
import { loadShopConfig } from "./config.server";
import { features } from "./plans";
import { scheduleReminders } from "./reminders.server";
import { ReservationService } from "./reservations.server";
import { SELECTION_ATTR, SelectionAttrSchema } from "./settings";
import { offerFreedPlace } from "./waitlist.server";
const reservations = new ReservationService(prisma);
type AdminGraphql = { graphql: (q: string, o?: { variables?: Record<string, unknown> }) => Promise<Response> };
export interface OrderPayload {
id: number;
admin_graphql_api_id: string;
name: string;
email?: string | null;
phone?: string | null;
note_attributes?: { name: string; value: string }[];
customer?: { first_name?: string | null; last_name?: string | null; email?: string | null; phone?: string | null } | null;
shipping_address?: { address1?: string; address2?: string; city?: string; zip?: string; latitude?: number | null; longitude?: number | null; phone?: string | null; first_name?: string; last_name?: string } | null;
line_items: { title: string; quantity: number; price: string; product_id: number | null; variant_id: number | null; requires_shipping?: boolean }[];
source_name?: string;
}
/** Webhook de-duplication by Shopify event id (falls back to topic+order). */
export async function firstDelivery(eventId: string, topic: string, shop: string): Promise<boolean> {
try {
await prisma.processedWebhook.create({ data: { id: eventId, topic, shop } });
return true;
} catch {
return false;
}
}
export async function handleOrderCreate(domain: string, order: OrderPayload, admin?: AdminGraphql) {
const cfg = await loadShopConfig(domain, { fresh: true });
const feeProductNumeric = cfg.shop.feeProductId?.split("/").pop();
const feeLines = order.line_items.filter((l) => feeProductNumeric && String(l.product_id) === feeProductNumeric);
const goods = order.line_items.filter((l) => !feeLines.includes(l));
const paidFee = feeLines.reduce((s, l) => s + Math.round(Number(l.price) * 100) * l.quantity, 0);
const raw = order.note_attributes?.find((a) => a.name === SELECTION_ATTR)?.value;
const parsed = raw ? SelectionAttrSchema.safeParse(safeJson(raw)) : null;
const attr = parsed?.success ? parsed.data : null;
const customerName = [order.customer?.first_name ?? order.shipping_address?.first_name, order.customer?.last_name ?? order.shipping_address?.last_name].filter(Boolean).join(" ") || undefined;
const sa = order.shipping_address;
const details = {
orderName: order.name,
timezone: cfg.shop.timezone,
customerName,
customerEmail: order.email ?? order.customer?.email ?? undefined,
customerPhone: order.phone ?? sa?.phone ?? order.customer?.phone ?? undefined,
address: sa ? { line1: [sa.address1, sa.address2].filter(Boolean).join(", "), city: sa.city, postal: sa.zip, lat: sa.latitude ?? undefined, lng: sa.longitude ?? undefined } : undefined,
items: goods.map((l) => ({ title: l.title, quantity: l.quantity })),
fee: paidFee,
configVersion: attr?.v ?? cfg.version,
snapshot: { attr: raw ?? null, source: order.source_name ?? null, paidFee },
};
const orderId = order.admin_graphql_api_id;
if (!attr) {
// Does this order need a time at all?
const cons = evaluateRules({ items: goods.map((l) => ({ productId: `gid://shopify/Product/${l.product_id}`, variantId: `gid://shopify/ProductVariant/${l.variant_id}`, title: l.title, quantity: l.quantity, price: 0 })) }, cfg.rules);
const needs = goods.length > 0 && (cfg.settings.requireScheduling || cons.requiresScheduling);
if (!needs) return { skipped: true };
const r = await reservations.confirm(cfg.shop.id, orderId, null, null, { dayCap: 0, slotCap: 0 }, new Date(), details);
await tagOrder(admin, orderId, ["Datewise: needs time"]);
return r;
}
const loc = cfg.locations.find((l) => l.id === attr.l);
const sel = { locationId: attr.l, method: attr.m, date: attr.d, slot: attr.s };
const caps = loc ? capsFor(loc, attr.m, attr.d, attr.s, features(cfg.shop.plan).peakDays ? cfg.peakDays : []) : { dayCap: 0, slotCap: 0 };
const [start, end] = attr.s.split("-");
const hold = await prisma.hold.findUnique({ where: { token: attr.t } });
const quoted = hold?.fee ?? attr.f;
const peak = cfg.peakDays.find((p) => p.dates.includes(attr.d) && (!p.locationIds?.length || p.locationIds.includes(attr.l)));
const r = await reservations.confirm(cfg.shop.id, orderId, attr.t, loc ? sel : null, caps, new Date(), {
...details,
startUtc: new Date(zonedToUtc(attr.d, start, cfg.shop.timezone)),
endUtc: new Date(zonedToUtc(attr.d, end, cfg.shop.timezone)),
peakDayId: peak?.id ?? null,
snapshot: { ...details.snapshot, quotedFee: quoted, location: loc?.name ?? null },
});
if (r.duplicate) return r;
if (r.ok && paidFee !== quoted) {
// Keep the place (the customer did book it) but flag the money difference for staff.
await prisma.booking.update({
where: { id: r.bookingId },
data: { reviewReason: `Fee paid ${(paidFee / 100).toFixed(2)} differs from quote ${(quoted / 100).toFixed(2)}` },
});
}
const label = cfg.settings.widget.labels[attr.m];
await tagOrder(admin, orderId, r.ok ? [`Datewise`, `${label}: ${attr.d}`] : ["Datewise: needs review"]);
await writeBookingMetafield(admin, orderId, { method: attr.m, location: loc?.name, date: attr.d, start, end, status: r.ok ? "confirmed" : "needs_review" });
if (r.ok) await scheduleReminders(r.bookingId);
return r;
}
export async function handleOrderCancelled(domain: string, order: { admin_graphql_api_id: string }) {
const cfg = await loadShopConfig(domain);
if (!cfg.settings.releaseOnCancel) {
const b = await prisma.booking.findUnique({ where: { shopId_orderId: { shopId: cfg.shop.id, orderId: order.admin_graphql_api_id } } });
if (b) await prisma.reminderJob.updateMany({ where: { bookingId: b.id, status: "pending" }, data: { status: "cancelled" } });
return { freed: null };
}
const r = await reservations.cancel(cfg.shop.id, order.admin_graphql_api_id);
if (r.freed) await offerFreedPlace(domain, r.freed);
return r;
}
export async function handleOrderFulfilled(domain: string, order: { admin_graphql_api_id: string }) {
const cfg = await loadShopConfig(domain);
await prisma.booking.updateMany({
where: { shopId: cfg.shop.id, orderId: order.admin_graphql_api_id, status: "confirmed" },
data: { status: "fulfilled" },
});
}
async function tagOrder(admin: AdminGraphql | undefined, id: string, tags: string[]) {
if (!admin) return;
await admin
.graphql(`#graphql
mutation($id: ID!, $tags: [String!]!) { tagsAdd(id: $id, tags: $tags) { userErrors { message } } }`, { variables: { id, tags } })
.then(async (r) => { const j = (await r.json()) as { data?: { tagsAdd?: { userErrors: { message: string }[] } } }; const ue = j.data?.tagsAdd?.userErrors ?? []; if (ue.length) console.error("tagsAdd userErrors", id, ue); })
.catch((e) => console.error("tagsAdd failed", e));
}
async function writeBookingMetafield(admin: AdminGraphql | undefined, ownerId: string, value: object) {
if (!admin) return;
await admin
.graphql(`#graphql
mutation($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { userErrors { message } } }`, {
variables: { m: [{ ownerId, namespace: "datewise", key: "booking", type: "json", value: JSON.stringify(value) }] },
})
.then(async (r) => { const j = (await r.json()) as { data?: { metafieldsSet?: { userErrors: { message: string }[] } } }; const ue = j.data?.metafieldsSet?.userErrors ?? []; if (ue.length) console.error("metafieldsSet userErrors", ownerId, ue); })
.catch((e) => console.error("metafieldsSet failed", e));
}
function safeJson(s: string): unknown {
try {
return JSON.parse(s);
} catch {
return null;
}
}