- 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>
163 lines
8.4 KiB
TypeScript
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;
|
|
}
|
|
}
|