From edf380d7abe16a9d1c0f428979f03896a29b7b1e Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 24 Sep 2026 23:14:59 -0400 Subject: [PATCH 1/2] fix(api): bootcamp gate uses the session's term; analytics "today" in ET - events.checkIn compared a member's bootcampTerm with currentTerm(), the wall clock, rather than the session's own bootcampTerm (which upsertSession stamps). The session's term now decides, falling back to the current term for events with none. - analyticsOverview's "check-ins today" started at server-local midnight. App Hosting runs in UTC, so the tile reset at 8pm Eastern, in the middle of evening events. It now starts at midnight America/New_York. --- .../src/.internal-tests/qr-checkin.test.ts | 45 +++++++++++++++++++ packages/api/src/routers/admin.ts | 16 ++++++- packages/api/src/routers/events.ts | 7 ++- 3 files changed, 64 insertions(+), 4 deletions(-) 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..dfcd0105 100644 --- a/packages/api/src/routers/admin.ts +++ b/packages/api/src/routers/admin.ts @@ -80,8 +80,20 @@ 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 now = new Date(); + const eastern = new Date( + now.toLocaleString("en-US", { timeZone: "America/New_York" }), + ); + const startOfToday = new Date( + now.getTime() - + (eastern.getHours() * 3_600_000 + + eastern.getMinutes() * 60_000 + + eastern.getSeconds() * 1000 + + now.getMilliseconds()), + ); 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", From de0a2182e98204282f66e6bd45fbcf4c71c4be8d Mon Sep 17 00:00:00 2001 From: aamoghS Date: Fri, 25 Sep 2026 09:27:17 -0400 Subject: [PATCH 2/2] fix(api): Eastern midnight that holds across DST changes Review follow-up. Subtracting the Eastern clock time from now only lands on midnight on a 24-hour day; on the spring-forward and fall-back Sundays the check-ins-today window started an hour off. startOfEasternDay now takes today's Eastern date and keeps whichever of the two Eastern offsets reads 00:00 there (the switch is at 2am, so midnight always exists exactly once). Tested on both DST days. --- .../src/.internal-tests/eastern-day.test.ts | 17 +++++++ packages/api/src/routers/admin.ts | 44 ++++++++++++++----- 2 files changed, 50 insertions(+), 11 deletions(-) create mode 100644 packages/api/src/.internal-tests/eastern-day.test.ts 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/routers/admin.ts b/packages/api/src/routers/admin.ts index dfcd0105..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) { @@ -83,17 +115,7 @@ export const adminRouter = createTRPCRouter({ // 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 now = new Date(); - const eastern = new Date( - now.toLocaleString("en-US", { timeZone: "America/New_York" }), - ); - const startOfToday = new Date( - now.getTime() - - (eastern.getHours() * 3_600_000 + - eastern.getMinutes() * 60_000 + - eastern.getSeconds() * 1000 + - now.getMilliseconds()), - ); + const startOfToday = startOfEasternDay(new Date()); const [ participantsResult,