Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions app/api/pickup-slots/route.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
57 changes: 57 additions & 0 deletions app/api/shop/[orderId]/select-timeslot/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> };

Expand Down Expand Up @@ -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 =
Expand Down
4 changes: 4 additions & 0 deletions container-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
76 changes: 76 additions & 0 deletions lib/scheduleGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = {};
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);
Expand Down
Loading