datewise/app/routes/api.customer.booking.tsx
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

67 lines
3.6 KiB
TypeScript

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 }));
};