import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; import { z } from "zod"; import prisma from "../db.server"; import { canCustomerReschedule } from "../engine/customer"; import type { Method } from "../engine/types"; import { authenticate, unauthenticated } from "../shopify.server"; import { rescheduleBooking, rescheduleOptions } from "../services/bookings.server"; import { loadShopConfig } from "../services/config.server"; import { rowToBooking } from "../services/reminders.server"; /** * Customer account / order status extension API. * The session token proves which shop and which customer is calling; we additionally * check that the order belongs to that customer before showing or changing anything. */ async function load(request: Request, orderId: string) { const { sessionToken, cors } = await authenticate.public.customerAccount(request); // dest is the bare shop domain for customer-account tokens; tolerate a URL too. const shop = sessionToken.dest.startsWith("http") ? new URL(sessionToken.dest).hostname : sessionToken.dest; const cfg = await loadShopConfig(shop); const b = await prisma.booking.findUnique({ where: { shopId_orderId: { shopId: cfg.shop.id, orderId } } }); // sessionToken.sub is the customer gid; verify via the Admin API that the order is theirs. if (b) { const { admin } = await unauthenticated.admin(shop); const r = await admin.graphql(`#graphql query($id: ID!) { order(id: $id) { customer { id } } }`, { variables: { id: orderId } }); const owner = ((await r.json()) as { data: { order: { customer: { id: string } | null } | null } }).data.order?.customer?.id; if (!owner || owner !== sessionToken.sub) return { cors, cfg, b: null }; } return { cors, cfg, b }; } export const loader = async ({ request }: LoaderFunctionArgs) => { if (request.method === "OPTIONS") return (await authenticate.public.customerAccount(request)).cors(new Response(null, { status: 204 })); const orderId = new URL(request.url).searchParams.get("orderId") ?? ""; const { cors, cfg, b } = await load(request, orderId); if (!b) return cors(Response.json({ booking: null })); const can = canCustomerReschedule(rowToBooking(b), cfg.settings.reschedule, Date.now()); const loc = cfg.locations.find((l) => l.id === b.locationId); const options = can.ok ? await rescheduleOptions(cfg.shop.domain, b.id, { customer: true }) : []; return cors( Response.json({ booking: { id: b.id, status: b.status, method: b.method, date: b.date, slotStart: b.slotStart, slotEnd: b.slotEnd, location: loc?.name ?? null, address: loc?.address ?? null, instructions: b.method === "pickup" ? loc?.pickupInstructions ?? null : null }, canReschedule: can.ok, reason: can.ok ? null : can.reason, deadline: can.ok ? new Date(can.deadline).toISOString() : null, options: options.slice(0, 120), }), ); }; const Body = z.object({ orderId: z.string(), date: z.string(), slot: z.string() }); export const action = async ({ request }: ActionFunctionArgs) => { const parsed = Body.safeParse(await request.json().catch(() => ({}))); const orderId = parsed.success ? parsed.data.orderId : ""; const { cors, cfg, b } = await load(request, orderId); if (!parsed.success || !b) return cors(Response.json({ ok: false, message: "Booking not found" }, { status: 404 })); const r = await rescheduleBooking({ domain: cfg.shop.domain, bookingId: b.id, sel: { locationId: b.locationId!, method: b.method as Method, date: parsed.data.date, slot: parsed.data.slot }, actor: "customer", }); return cors(Response.json(r, { status: r.ok ? 200 : 409 })); };