67 lines
3.6 KiB
TypeScript
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 }));
|
|
};
|