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
17 changes: 17 additions & 0 deletions packages/api/src/.internal-tests/eastern-day.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
45 changes: 45 additions & 0 deletions packages/api/src/.internal-tests/qr-checkin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
38 changes: 36 additions & 2 deletions packages/api/src/routers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions packages/api/src/routers/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading