import type { LoaderFunctionArgs } from "@remix-run/node"; import { DateTime } from "luxon"; import type { Method } from "@prisma/client"; import { authenticate } from "../shopify.server"; import db from "../db.server"; import { getAvailability } from "../services/scheduling.server"; // Public endpoint, reachable only through Shopify's App Proxy (signature // verified by authenticate.public.appProxy) — this is what the storefront // Theme App Extension calls. Requests to https://{shop}/apps/scheduling/* // forward here because shopify.app.toml's [app_proxy].url already includes // the /apps/scheduling prefix, so this file's path (apps.scheduling.*) // mirrors the shop-facing URL exactly. // // No capacity consumption is wired up yet (Booking/SlotHold don't exist // until Phase 4), so every slot's `consumed` is implicitly 0 here — that's // expected for Phase 3, not a bug to fix in this file. const VALID_METHODS = new Set(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]); const MAX_DAYS = 60; const DEFAULT_DAYS = 14; function toIsoDate(date: Date): string { return date.toISOString().slice(0, 10); } export const loader = async ({ request }: LoaderFunctionArgs) => { const { session } = await authenticate.public.appProxy(request); if (!session) { return Response.json({ error: "Shop not found" }, { status: 404 }); } const url = new URL(request.url); const methodParam = url.searchParams.get("method"); const locationIdParam = url.searchParams.get("locationId"); const daysParam = Number(url.searchParams.get("days") ?? DEFAULT_DAYS); const days = Number.isFinite(daysParam) && daysParam > 0 ? Math.min(daysParam, MAX_DAYS) : DEFAULT_DAYS; if (!methodParam || !VALID_METHODS.has(methodParam as Method)) { return Response.json({ error: "Invalid or missing method" }, { status: 400 }); } const method = methodParam as Method; const location = locationIdParam ? await db.location.findFirst({ where: { id: locationIdParam, shopDomain: session.shop, active: true }, }) : await db.location.findFirst({ where: { shopDomain: session.shop, active: true }, orderBy: { createdAt: "asc" }, }); if (!location) { return Response.json({ error: "No active location configured" }, { status: 404 }); } const now = DateTime.now().setZone(location.timezone); const startDate = now.toISODate()!; const endDate = now.plus({ days }).toISODate()!; const rangeStart = DateTime.fromISO(startDate, { zone: "utc" }).toJSDate(); const rangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).toJSDate(); const [slotTemplates, overrides, blackouts] = await Promise.all([ db.slotTemplate.findMany({ where: { shopDomain: session.shop, locationId: location.id, method }, }), db.slotOverride.findMany({ where: { shopDomain: session.shop, locationId: location.id, method, date: { gte: rangeStart, lte: rangeEnd }, }, }), db.blackoutDate.findMany({ where: { shopDomain: session.shop, date: { gte: rangeStart, lte: rangeEnd }, AND: [{ OR: [{ locationId: location.id }, { locationId: null }] }, { OR: [{ method }, { method: null }] }], }, }), ]); const availability = getAvailability({ timezone: location.timezone, dateRange: { startDate, endDate }, slotTemplates: slotTemplates.map((t) => ({ weekday: t.weekday, startMin: t.startMin, endMin: t.endMin, capacity: t.capacity, cutoffMin: t.cutoffMin, leadTimeMin: t.leadTimeMin, })), overrides: overrides.map((o) => ({ date: toIsoDate(o.date), closed: o.closed, startMin: o.startMin, endMin: o.endMin, capacity: o.capacity, })), blackoutDates: blackouts.map((b) => ({ date: toIsoDate(b.date) })), now, }); return Response.json({ locationId: location.id, locationName: location.name, timezone: location.timezone, method, dates: availability, }); };