diff --git a/app/api/pickup-slots/route.ts b/app/api/pickup-slots/route.ts index 1f84e37..eb42975 100644 --- a/app/api/pickup-slots/route.ts +++ b/app/api/pickup-slots/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from "next/server"; import { getPayloadClient } from "../../../lib/payload"; +import { SHOP_TIMEZONE, slotInstant } from "../../../lib/scheduleGenerator"; /** * GET /api/pickup-slots — List available pickup timeslots. @@ -27,7 +28,15 @@ export async function GET(request: NextRequest) { const payload = await getPayloadClient(); const now = new Date(); - const todayStr = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, "0")}-${now.getDate().toString().padStart(2, "0")}`; + // "Today" must be computed in the SHOP's timezone, not the server's + // (production runs in UTC). Using the server's local date here could + // drop or include a day's slots incorrectly around midnight. + const todayStr = new Intl.DateTimeFormat("en-CA", { + timeZone: SHOP_TIMEZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(now); // en-CA yields YYYY-MM-DD // Fetch all active future timeslots (we filter capacity + notice in JS // because MongoDB can't easily express "maxCapacity is null OR @@ -108,12 +117,14 @@ export async function GET(request: NextRequest) { } } - // Compute the slot's start datetime + // Compute the slot's real start instant. The slot's date + startTime + // are stored as bare wall-clock strings (no timezone), so we resolve + // them against the shop's timezone. Without this, `new Date("...T09:00")` + // would be parsed in the SERVER's timezone (UTC in production), shifting + // the comparison by the shop's UTC offset and breaking the notice period. const slotDateStr = typeof slot.date === "string" ? slot.date.split("T")[0] : ""; - const slotStart = new Date( - `${slotDateStr}T${slot.startTime || "00:00"}:00`, - ); + const slotStart = slotInstant(slotDateStr, slot.startTime || "00:00"); const cutoff = new Date(now.getTime() + noticeHours * 60 * 60 * 1000); if (slotStart <= cutoff) { diff --git a/app/api/shop/[orderId]/select-timeslot/route.ts b/app/api/shop/[orderId]/select-timeslot/route.ts index 01fa7cd..1a7925c 100644 --- a/app/api/shop/[orderId]/select-timeslot/route.ts +++ b/app/api/shop/[orderId]/select-timeslot/route.ts @@ -3,6 +3,7 @@ import { pickupProfileToHtml, sendOrderConfirmationEmail } from "@/lib/email"; import { getAuthenticatedCustomer } from "../../../../../lib/auth"; import { notifyPickupSlotSelected } from "../../../../../lib/discord"; import { getPayloadClient } from "../../../../../lib/payload"; +import { slotInstant } from "../../../../../lib/scheduleGenerator"; type RouteContext = { params: Promise<{ orderId: string }> }; @@ -99,6 +100,62 @@ export async function POST(request: NextRequest, context: RouteContext) { ); } + // ── Minimum notice period check ────────────────────────────── + // Mirror the filtering done in GET /api/pickup-slots so a customer can't + // book a slot inside the notice window by POSTing a timeslotId directly + // (or from a stale list). Resolve notice hours: schedule override → + // global default → 24h fallback. Times are interpreted in the shop's + // timezone via slotInstant so the comparison is timezone-safe. + let noticeHours = 24; + try { + const settings = await payload.findGlobal({ slug: "schedule-settings" }); + if ( + settings?.defaultMinimumNoticeHours != null && + typeof settings.defaultMinimumNoticeHours === "number" + ) { + noticeHours = settings.defaultMinimumNoticeHours; + } + } catch { + // Use default + } + + const scheduleId = + typeof timeslot.schedule === "object" && timeslot.schedule + ? (timeslot.schedule as { id: string }).id + : typeof timeslot.schedule === "string" + ? timeslot.schedule + : null; + if (scheduleId) { + try { + const schedule = await payload.findByID({ + collection: "schedules", + id: scheduleId, + }); + if ( + schedule?.minimumNoticeHours != null && + typeof schedule.minimumNoticeHours === "number" + ) { + noticeHours = schedule.minimumNoticeHours; + } + } catch { + // Use resolved default + } + } + + const slotDateStr = + typeof timeslot.date === "string" ? timeslot.date.split("T")[0] : ""; + const slotStart = slotInstant(slotDateStr, timeslot.startTime || "00:00"); + const cutoff = new Date(Date.now() + noticeHours * 60 * 60 * 1000); + if (slotStart <= cutoff) { + return NextResponse.json( + { + error: + "This timeslot is too soon to book. Please choose a later slot.", + }, + { status: 409 }, + ); + } + // An order is "changing" its timeslot (rather than selecting for the // first time) whenever it already has one — i.e. any status past PAID. const isChangingTimeslot = diff --git a/container-worker.js b/container-worker.js index dbebfc7..cbb5a7a 100644 --- a/container-worker.js +++ b/container-worker.js @@ -64,6 +64,10 @@ export class PrimalPrinting extends Container { DISCORD_WEBHOOK_URL: env.DISCORD_WEBHOOK_URL ?? "", // Cron CRON_SECRET: env.CRON_SECRET ?? "", + // Shop timezone — used to interpret timeslot date/time strings when + // enforcing the minimum notice period. Keep the default in sync with + // the code fallback in lib/scheduleGenerator.ts and .env.local. + SHOP_TIMEZONE: env.SHOP_TIMEZONE ?? "Pacific/Auckland", // Public env vars (baked into client bundle at build time, but // still useful for server-side code that reads them) NEXT_PUBLIC_MINIMUM_ITEMS_FOR_DISCOUNT: diff --git a/env.d.ts b/env.d.ts index 4e11823..b68c077 100644 --- a/env.d.ts +++ b/env.d.ts @@ -51,6 +51,14 @@ declare namespace NodeJS { /** Discord webhook URL for admin notifications (bank transfers, pickup slots). */ DISCORD_WEBHOOK_URL?: string; + /** + * IANA timezone the shop operates in (e.g. "Pacific/Auckland"). Used to + * interpret timeslot date + startTime strings as real instants when + * enforcing the minimum notice period, so the calculation is correct + * regardless of the server's own timezone. Defaults to "Pacific/Auckland". + */ + SHOP_TIMEZONE?: string; + // ── Public / client-side config ────────────────────────────────── /** Minimum items to trigger a discount. Defaults to "2" in code. */ NEXT_PUBLIC_MINIMUM_ITEMS_FOR_DISCOUNT?: string; diff --git a/lib/scheduleGenerator.ts b/lib/scheduleGenerator.ts index 1525d1a..92e8d96 100644 --- a/lib/scheduleGenerator.ts +++ b/lib/scheduleGenerator.ts @@ -109,6 +109,82 @@ function slotKey(date: string, startTime: string, endTime: string): string { return `${date}|${startTime}|${endTime}`; } +/** + * IANA timezone the shop operates in. Timeslot `date` (YYYY-MM-DD) and + * `startTime`/`endTime` (HH:MM) are stored as bare wall-clock strings with no + * timezone, so they must be interpreted in the shop's local timezone to be + * turned into real instants. Defaults to Pacific/Auckland — keep this in sync + * with the SHOP_TIMEZONE default in container-worker.js and .env.local. + */ +export const SHOP_TIMEZONE = process.env.SHOP_TIMEZONE || "Pacific/Auckland"; + +/** + * Compute the UTC offset (in minutes) of a given IANA timezone at a specific + * instant, e.g. +780 for Pacific/Auckland during NZDT. Positive means the zone + * is ahead of UTC. Uses Intl so DST is handled automatically for the date. + */ +function tzOffsetMinutes(instant: Date, timeZone: string): number { + // Format the instant as wall-clock parts in the target timezone, then read + // those parts back as if they were UTC. The difference between that pseudo-UTC + // value and the real instant is the zone's offset at that moment. + const dtf = new Intl.DateTimeFormat("en-US", { + timeZone, + hour12: false, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + const parts = dtf.formatToParts(instant); + const map: Record = {}; + for (const p of parts) { + if (p.type !== "literal") map[p.type] = Number(p.value); + } + // Intl may emit hour "24" at midnight — normalise to 0. + const hour = map.hour === 24 ? 0 : map.hour; + const asUTC = Date.UTC( + map.year, + map.month - 1, + map.day, + hour, + map.minute, + map.second, + ); + return (asUTC - instant.getTime()) / 60000; +} + +/** + * Convert a timeslot's wall-clock `date` (YYYY-MM-DD) and `time` (HH:MM), + * interpreted in `timeZone`, into the correct absolute Date (instant). + * + * This is timezone-safe: the same slot resolves to the same instant regardless + * of the server's own timezone, so notice-period math is correct in production + * (where the server typically runs in UTC) as well as locally. DST transitions + * are handled by resolving the offset for that specific wall-clock moment. + */ +export function slotInstant( + date: string, + time: string, + timeZone: string = SHOP_TIMEZONE, +): Date { + const dateStr = date.includes("T") ? date.split("T")[0] : date; + const [year, month, day] = dateStr.split("-").map(Number); + const [hour, minute] = (time || "00:00").split(":").map(Number); + + // First guess: treat the wall-clock time as if it were UTC. + const utcGuess = Date.UTC(year, month - 1, day, hour, minute, 0); + // The offset near that instant tells us how far to shift to get the real + // instant. We resolve the offset twice to correctly handle the rare case + // where the guess lands on the far side of a DST boundary. + const offset1 = tzOffsetMinutes(new Date(utcGuess), timeZone); + const candidate = utcGuess - offset1 * 60000; + const offset2 = tzOffsetMinutes(new Date(candidate), timeZone); + const finalMs = offset2 === offset1 ? candidate : utcGuess - offset2 * 60000; + return new Date(finalMs); +} + /** Parse "HH:MM" into total minutes since midnight. */ function parseTime(t: string): number { const [h, m] = t.split(":").map(Number);