diff --git a/packages/api/src/.internal-tests/eastern-day.test.ts b/packages/api/src/.internal-tests/eastern-day.test.ts new file mode 100644 index 00000000..add08e98 --- /dev/null +++ b/packages/api/src/.internal-tests/eastern-day.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { startOfEasternDay } from "../routers/admin"; + +describe("startOfEasternDay", () => { + it.each([ + // Evening in Atlanta is already tomorrow in UTC; today still began at + // Eastern midnight. + ["2026-09-25T00:30:00Z", "2026-09-24T04:00:00.000Z"], + ["2026-12-25T03:00:00Z", "2026-12-24T05:00:00.000Z"], + // The DST days: midnight is before the 2am switch, so it keeps the + // offset the day started with. + ["2026-03-08T20:00:00Z", "2026-03-08T05:00:00.000Z"], + ["2026-11-01T20:00:00Z", "2026-11-01T04:00:00.000Z"], + ])("%s starts its Eastern day at %s", (now, expected) => { + expect(startOfEasternDay(new Date(now)).toISOString()).toBe(expected); + }); +}); diff --git a/packages/api/src/.internal-tests/qr-checkin.test.ts b/packages/api/src/.internal-tests/qr-checkin.test.ts index 26d2dac5..5f4ddf18 100644 --- a/packages/api/src/.internal-tests/qr-checkin.test.ts +++ b/packages/api/src/.internal-tests/qr-checkin.test.ts @@ -377,6 +377,51 @@ describe("QR check-in", () => { expect(mockInsert).toHaveBeenCalled(); }); + // The session's own term decides, not the day it falls on. + it("admits a member enrolled for the session's own term", async () => { + mockFindFirst.mockImplementation((table: string) => { + if (table === "events") + return clubEvent({ + bootcampOnly: true, + bootcampWeek: 3, + bootcampTerm: "2031-spring", + }); + if (table === "members") + return { ...activeMember, bootcampTerm: "2031-spring" }; + return undefined; + }); + mockUpdate.mockReturnValue([{ id: CLUB_EVENT }]); + mockInsert.mockReturnValue([{ id: "checkin_1" }]); + + const caller = appRouter.createCaller(createMockCtx("member_user")); + + await expect( + caller.events.checkIn({ qrCode: QR_OLD }), + ).resolves.toMatchObject({ success: true }); + }); + + it("turns away a current-term member from another term's session", async () => { + mockFindFirst.mockImplementation((table: string) => { + if (table === "events") + return clubEvent({ + bootcampOnly: true, + bootcampWeek: 3, + bootcampTerm: "2031-spring", + }); + if (table === "members") + return { ...activeMember, bootcampTerm: currentTerm() }; + return undefined; + }); + + const caller = appRouter.createCaller(createMockCtx("member_user")); + const err: any = await caller.events + .checkIn({ qrCode: QR_OLD }) + .catch((e: unknown) => e); + + expect(err.code).toBe("FORBIDDEN"); + expect(mockInsert).not.toHaveBeenCalled(); + }); + it("still refuses a second scan of the same badge", async () => { mockFindFirst.mockImplementation((table: string) => { if (table === "events") return bootcampSession(); diff --git a/packages/api/src/routers/admin.ts b/packages/api/src/routers/admin.ts index 0bee00d2..e0aac57c 100644 --- a/packages/api/src/routers/admin.ts +++ b/packages/api/src/routers/admin.ts @@ -18,6 +18,38 @@ import { compareTerms, currentTerm } from "@query/db/services/membership"; import { isExpiredAdmin, isStaffRole } from "../types/portal-context"; import type { DrizzleDB } from "@query/db"; +const easternDate = new Intl.DateTimeFormat("en-CA", { + timeZone: "America/New_York", + year: "numeric", + month: "2-digit", + day: "2-digit", +}); +const easternHour = new Intl.DateTimeFormat("en-US", { + timeZone: "America/New_York", + hour: "numeric", + hourCycle: "h23", +}); + +/** + * The instant Atlanta's current day began. Found by trying both Eastern + * offsets for today's date and keeping the one that reads 00:00 there, so it + * holds on the 23- and 25-hour days around a DST change too (the switch is at + * 2am, so midnight itself always exists exactly once). + */ +export function startOfEasternDay(now: Date): Date { + const ymd = easternDate.format(now); + const candidates = ["-04:00", "-05:00"].map( + (offset) => new Date(`${ymd}T00:00:00${offset}`), + ); + return ( + candidates.find( + (candidate) => + easternDate.format(candidate) === ymd && + Number(easternHour.format(candidate)) === 0, + ) ?? candidates[1]! + ); +} + export const adminRouter = createTRPCRouter({ isAdmin: protectedProcedure.query(async ({ ctx }) => { if (!ctx.userId) { @@ -80,8 +112,10 @@ export const adminRouter = createTRPCRouter({ }>( cacheKey, async () => { - const startOfToday = new Date(); - startOfToday.setHours(0, 0, 0, 0); + // Midnight in Atlanta, not on the server: App Hosting runs in UTC, so + // "today" used to roll over at 8pm Eastern, mid-way through evening + // events. + const startOfToday = startOfEasternDay(new Date()); const [ participantsResult, diff --git a/packages/api/src/routers/events.ts b/packages/api/src/routers/events.ts index eba32d17..97d9dd55 100644 --- a/packages/api/src/routers/events.ts +++ b/packages/api/src/routers/events.ts @@ -390,8 +390,11 @@ export const eventRouter = createTRPCRouter({ } // Bought per semester, so last term's seat is not this term's. Officers can - // still check somebody in by hand. - if (event.bootcampOnly && member?.bootcampTerm !== currentTerm()) { + // still check somebody in by hand. Measured against the session's own + // term (upsertSession stamps it), not the wall clock, so the answer + // cannot change with the day a session happens to fall on. + const sessionTerm = event.bootcampTerm ?? currentTerm(); + if (event.bootcampOnly && member?.bootcampTerm !== sessionTerm) { throw new TRPCError({ code: "FORBIDDEN", message: "This session is for bootcamp members this semester",