From 5f0e07667123531803d0da8b5f9d00eebd03655b Mon Sep 17 00:00:00 2001 From: aamoghS Date: Wed, 30 Sep 2026 13:50:09 -0400 Subject: [PATCH 1/3] fix: second audit pass on judging, admins, terms and teams - Withdrawing a project drops its unscored queue slots, and the judge page moves on to the next table when the slot on screen is gone. - assignToHackathon no longer queues a project twice; isJudge rejects a malformed id as 400 instead of a Postgres 500; judge min/max per judge must be whole numbers with max >= min. - The last-super-admin guard also covers deactivation and ending the term, and no longer counts expired super admins. - Terms and semester ends are computed in Eastern time. The last six hours of a semester count as the next one, so renewing a membership stored on the old UTC boundary still buys a full semester. - leaveTeam and disbandTeam lock the team row, serialising with joins. - Chunk-error recovery reloads again after a later deploy; ModalWrapper no longer steals focus when its onClose changes; Hacklytics anchors drop content-visibility so nav taps land on the section. --- .../hackathon-admin-edge.test.ts | 22 +++++ .../.internal-tests/hackathon-flow.test.ts | 6 ++ .../src/.internal-tests/judge-edge.test.ts | 46 ++++++++++ packages/api/src/middleware/procedures.ts | 11 +++ packages/api/src/routers/admin.ts | 25 ++++-- packages/api/src/routers/hackathon/content.ts | 21 ++++- packages/api/src/routers/judge/admin.ts | 28 +++++-- packages/api/src/routers/team.ts | 19 ++++- .../db/src/services/membership-edge.test.ts | 83 ++++++++++++++----- packages/db/src/services/membership.test.ts | 16 ++-- packages/db/src/services/membership.ts | 52 ++++++++---- sites/hacklytics2027/app/globals.css | 7 +- .../(portal)/hackathons/[id]/judge/page.tsx | 38 ++++++++- .../components/portal/ModalWrapper.tsx | 13 ++- sites/mainweb/lib/chunk-error.ts | 16 +++- 15 files changed, 328 insertions(+), 75 deletions(-) diff --git a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts index 396ebceb..7a4205bf 100644 --- a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts +++ b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts @@ -1186,6 +1186,28 @@ describe("Hackathon admin management edge cases", () => { caller.admin.update({ adminId: ADMIN_ROW, role: "moderator" }), ).rejects.toThrow(/super admin/i); }); + + // Ending the term now removes a super admin as surely as demoting them, + // and one whose term already ended cannot step in. + it("refuses to end the last live super admin's term", async () => { + const caller = adminCaller({}, "super_admin"); + mockFindMany.mockReturnValue([ + adminRow("super_admin"), + { + id: "other_admin_row", + role: "super_admin", + isActive: true, + expiresAt: new Date("2020-01-01"), + }, + ]); + + await expect( + caller.admin.update({ + adminId: ADMIN_ROW, + expiresAt: new Date("2020-06-01"), + }), + ).rejects.toThrow(/last super admin/i); + }); }); // ===================================================================== diff --git a/packages/api/src/.internal-tests/hackathon-flow.test.ts b/packages/api/src/.internal-tests/hackathon-flow.test.ts index 41701769..d5b71732 100644 --- a/packages/api/src/.internal-tests/hackathon-flow.test.ts +++ b/packages/api/src/.internal-tests/hackathon-flow.test.ts @@ -78,6 +78,12 @@ vi.mock("@query/db", () => { groupBy: vi.fn().mockResolvedValue([]), limit: vi.fn().mockResolvedValue([]), offset: vi.fn().mockResolvedValue([]), + // Row locks. The only locked read in these flows is the team row + // leave/disband take, answered from the same wiring as findFirst. + for: vi.fn().mockImplementation(async () => { + const team = mockFindFirst("hackathonTeams"); + return team ? [team] : []; + }), })), orderBy: vi.fn().mockResolvedValue([{ count: 0 }]), groupBy: vi.fn().mockResolvedValue([]), diff --git a/packages/api/src/.internal-tests/judge-edge.test.ts b/packages/api/src/.internal-tests/judge-edge.test.ts index bc6fa1c2..3e80d96c 100644 --- a/packages/api/src/.internal-tests/judge-edge.test.ts +++ b/packages/api/src/.internal-tests/judge-edge.test.ts @@ -2106,4 +2106,50 @@ describe("Judge edge cases", () => { ).rejects.toMatchObject({ code: "FORBIDDEN" }); }); }); + + // ===================================================================== + /** + * An organiser pulling a project. Marking the judging entry withdrawn was + * not enough: every queue read kept routing judges to the pulled table. + */ + describe("14. Organiser withdrawal", () => { + const wireWithdraw = () => + mockFindFirst.mockImplementation((table: string) => { + if (table === "admins") return ADMIN_ROW; + if (table === "hackathonProjects") + return { id: PROJECT_A, hackathonId: HACK_A, status: "submitted" }; + return undefined; + }); + + it("drops the project's unscored queue slots", async () => { + wireWithdraw(); + mockUpdate.mockReturnValue([{ id: "judging_row" }]); + + await adminCaller().hackathon.adminWithdrawProject({ + projectId: PROJECT_A, + }); + + expect(mockDelete).toHaveBeenCalledTimes(1); + }); + + it("touches no queue when the project was never promoted", async () => { + wireWithdraw(); + mockUpdate.mockReturnValue([]); + + await adminCaller().hackathon.adminWithdrawProject({ + projectId: PROJECT_A, + }); + + expect(mockDelete).not.toHaveBeenCalled(); + }); + }); + + // isJudge reads ids before the procedure's schema runs, and each one goes + // into a uuid column: a malformed id was a Postgres error and a 500. + it("refuses a malformed id before it reaches the database", async () => { + await expect( + judgeCaller().judge.getNextTable({ hackathonId: "not-a-uuid" }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(mockFindFirst).not.toHaveBeenCalled(); + }); }); diff --git a/packages/api/src/middleware/procedures.ts b/packages/api/src/middleware/procedures.ts index 7cfae94b..fd6dd3a9 100644 --- a/packages/api/src/middleware/procedures.ts +++ b/packages/api/src/middleware/procedures.ts @@ -162,6 +162,9 @@ export const isProjectLeader = protectedProcedure.use(async ({ ctx, next }) => { // Verifies the caller is an active judge for a specific hackathon. Cached 60s // per user per hackathon. +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + export const isJudge = protectedProcedure.use(async ({ ctx, next, getRawInput }) => { const db = ctx.db as NonNullable; @@ -170,6 +173,14 @@ export const isJudge = protectedProcedure.use(async ({ ctx, next, getRawInput }) let hackathonId: string | undefined; if (rawInput && typeof rawInput === "object") { const inputObj = rawInput as Record; + // This runs before the procedure's own schema, and every id below goes into + // a uuid column: a malformed one made Postgres throw, surfacing as a 500. + for (const key of ["hackathonId", "projectId", "queueId"]) { + const value = inputObj[key]; + if (typeof value === "string" && !UUID_PATTERN.test(value)) { + throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid ${key}` }); + } + } if (typeof inputObj.hackathonId === "string") { hackathonId = inputObj.hackathonId; } else if (typeof inputObj.projectId === "string") { diff --git a/packages/api/src/routers/admin.ts b/packages/api/src/routers/admin.ts index 9dd9971a..4a259e83 100644 --- a/packages/api/src/routers/admin.ts +++ b/packages/api/src/routers/admin.ts @@ -394,22 +394,29 @@ export const adminRouter = createTRPCRouter({ }); } - // Only a super admin can hand the role back out, so demoting the last one - // locks the org out of admin management with no in-app recovery. - if ( + // Only a super admin can hand the role back out, so losing the last one + // locks the org out of admin management with no in-app recovery. Demoting + // is one way; deactivating or ending the term now are the others, and an + // expired super admin is not a remaining one. + const removesSuperAdmin = targetAdmin.role === "super_admin" && - input.role && - input.role !== "super_admin" - ) { + ((input.role !== undefined && input.role !== "super_admin") || + input.isActive === false || + (input.expiresAt != null && input.expiresAt.getTime() <= Date.now())); + + if (removesSuperAdmin) { const superAdmins = await (ctx.db as DrizzleDB).query.admins.findMany({ where: and(eq(admins.role, "super_admin"), eq(admins.isActive, true)), - columns: { id: true }, + columns: { id: true, expiresAt: true }, }); - if (!superAdmins.some((other) => other.id !== targetAdmin.id)) { + const remaining = superAdmins.filter( + (other) => other.id !== targetAdmin.id && !isExpiredAdmin(other), + ); + if (remaining.length === 0) { throw new TRPCError({ code: "BAD_REQUEST", - message: "Cannot demote the last super admin", + message: "Cannot remove the last super admin", }); } } diff --git a/packages/api/src/routers/hackathon/content.ts b/packages/api/src/routers/hackathon/content.ts index a12272ca..620d0e45 100644 --- a/packages/api/src/routers/hackathon/content.ts +++ b/packages/api/src/routers/hackathon/content.ts @@ -5,6 +5,7 @@ import { hackathonParticipants, hackathonProjects, hackathonResults, + judgeQueue, judgingProjects, } from "@query/db"; import { eq, and, inArray, isNotNull } from "drizzle-orm"; @@ -108,10 +109,26 @@ export const hackathonContentRouter = createTRPCRouter({ // The judging entry has to go with it, or the CONFLICT message above is a // lie: judges keep being routed to the table, the votes keep counting, and // the project can still be published as a placing. - await db + const pulled = await db .update(judgingProjects) .set({ withdrawnAt: new Date() }) - .where(eq(judgingProjects.sourceProjectId, input.projectId)); + .where(eq(judgingProjects.sourceProjectId, input.projectId)) + .returning({ id: judgingProjects.id }); + + // Unscored slots go too. Every queue read (next table, vote, complete and + // next) otherwise kept sending judges to a table scan-to-start refuses. + // Completed slots stay: their votes are the record. + if (pulled.length > 0) { + await db.delete(judgeQueue).where( + and( + inArray( + judgeQueue.projectId, + pulled.map((row) => row.id), + ), + eq(judgeQueue.isCompleted, false), + ), + ); + } await recordAdminAction(db, { userId: ctx.userId, diff --git a/packages/api/src/routers/judge/admin.ts b/packages/api/src/routers/judge/admin.ts index 177c46a5..17f228d9 100644 --- a/packages/api/src/routers/judge/admin.ts +++ b/packages/api/src/routers/judge/admin.ts @@ -478,13 +478,27 @@ export const judgeAdminRouter = createTRPCRouter({ ? eligibleProjects : shuffleArray(eligibleProjects); - if (assignedProjects.length > 0) { + // initializeQueue may already have built this judge a queue, and + // judge_queue has no unique on (judge, project): appending the full pool + // would put every project in it twice. + const queued = await (ctx.db as DrizzleDB).query.judgeQueue.findMany({ + where: and( + eq(judgeQueue.judgeId, input.judgeId), + eq(judgeQueue.hackathonId, input.hackathonId), + ), + columns: { projectId: true, order: true }, + }); + const alreadyQueued = new Set(queued.map((row) => row.projectId)); + const lastOrder = queued.reduce((max, row) => Math.max(max, row.order), 0); + const toQueue = assignedProjects.filter((p) => !alreadyQueued.has(p.id)); + + if (toQueue.length > 0) { await (ctx.db as DrizzleDB).insert(judgeQueue).values( - assignedProjects.map((p, idx) => ({ + toQueue.map((p, idx) => ({ judgeId: input.judgeId, hackathonId: input.hackathonId, projectId: p.id, - order: idx + 1, + order: lastOrder + idx + 1, })), ); } @@ -924,8 +938,8 @@ export const judgeAdminRouter = createTRPCRouter({ .input( z.object({ hackathonId: z.string().uuid(), - minProjects: z.number().min(1).default(3), - maxProjects: z.number().min(1).default(9), + minProjects: z.number().int().min(1).default(3), + maxProjects: z.number().int().min(1).default(9), shuffle: z.boolean().default(true), // False (default) randomizes special-label/sponsor pools; true keeps them in // table order. @@ -934,6 +948,10 @@ export const judgeAdminRouter = createTRPCRouter({ // Rebuild even though judging is live or work is done. Completed slots still // carry over; this only waives the refusal, so the admin saw the count. force: z.boolean().default(false), + }).refine((input) => input.maxProjects >= input.minProjects, { + // min 9 / max 3 silently capped every judge at 3. + message: "Maximum projects per judge must be at least the minimum.", + path: ["maxProjects"], }), ) .mutation(async ({ ctx, input }) => { diff --git a/packages/api/src/routers/team.ts b/packages/api/src/routers/team.ts index 5e094859..1a4b250c 100644 --- a/packages/api/src/routers/team.ts +++ b/packages/api/src/routers/team.ts @@ -476,9 +476,14 @@ export const teamRouter = createTRPCRouter({ try { return await (ctx.db as NonNullable).transaction( async (tx) => { - const team = await tx.query.hackathonTeams.findFirst({ - where: eq(hackathonTeams.id, participant.teamId!), - }); + // Locked, so the member count below is current: a join commits its + // seat under this row's lock, and deleting the team around a join + // still in flight dropped that member silently. + const [team] = await tx + .select() + .from(hackathonTeams) + .where(eq(hackathonTeams.id, participant.teamId!)) + .for("update"); if (!team) throw new TRPCError({ @@ -612,6 +617,14 @@ export const teamRouter = createTRPCRouter({ try { return await (ctx.db as NonNullable).transaction( async (tx) => { + // Same lock joinTeam's seat update takes: a join still in flight + // finishes first or finds no team, instead of landing on a deleted one. + await tx + .select({ id: hackathonTeams.id }) + .from(hackathonTeams) + .where(eq(hackathonTeams.id, team.id)) + .for("update"); + const project = await tx.query.hackathonProjects.findFirst({ where: eq(hackathonProjects.teamId, team.id), }); diff --git a/packages/db/src/services/membership-edge.test.ts b/packages/db/src/services/membership-edge.test.ts index 0ed5c0fb..57632592 100644 --- a/packages/db/src/services/membership-edge.test.ts +++ b/packages/db/src/services/membership-edge.test.ts @@ -303,46 +303,65 @@ describe("isBootcampAddOnOnly — edges", () => { describe("currentTerm — boundaries", () => { it("calls January spring", () => { - expect(currentTerm(new Date(2026, 0, 1))).toBe("2026-spring"); + expect(currentTerm(new Date("2026-01-01T00:00:00-05:00"))).toBe("2026-spring"); }); /** The split is the end of May: month index 4 is still spring. */ it("calls the last day of May spring", () => { - expect(currentTerm(new Date(2026, 4, 31))).toBe("2026-spring"); + expect(currentTerm(new Date("2026-05-31T00:00:00-04:00"))).toBe("2026-spring"); }); it("calls the first day of June fall", () => { - expect(currentTerm(new Date(2026, 5, 1))).toBe("2026-fall"); + expect(currentTerm(new Date("2026-06-01T00:00:00-04:00"))).toBe("2026-fall"); }); it("sells the autumn bootcamp over the summer", () => { - expect(currentTerm(new Date(2026, 6, 15))).toBe("2026-fall"); + expect(currentTerm(new Date("2026-07-15T00:00:00-04:00"))).toBe("2026-fall"); }); it("calls the last day of December fall, not next spring", () => { - expect(currentTerm(new Date(2026, 11, 31))).toBe("2026-fall"); + expect(currentTerm(new Date("2026-12-31T00:00:00-05:00"))).toBe("2026-fall"); }); it("rolls into the new year's spring on 1 January", () => { - expect(currentTerm(new Date(2027, 0, 1))).toBe("2027-spring"); + expect(currentTerm(new Date("2027-01-01T00:00:00-05:00"))).toBe("2027-spring"); }); it("handles a leap day", () => { - expect(currentTerm(new Date(2028, 1, 29))).toBe("2028-spring"); + expect(currentTerm(new Date("2028-02-29T00:00:00-05:00"))).toBe("2028-spring"); + }); + + // 9pm Eastern on May 31 is already June in UTC, where the server runs. + it("keeps a May 31 evening purchase in spring", () => { + expect(currentTerm(new Date("2026-05-31T21:00:00-04:00"))).toBe("2026-spring"); + }); + + it("keeps a New Year's Eve evening purchase in fall", () => { + expect(currentTerm(new Date("2026-12-31T21:00:00-05:00"))).toBe("2026-fall"); }); }); describe("semesterEndDate — boundaries", () => { - const springEnd = (year: number) => new Date(year, 4, 31, 23, 59, 59, 999); - const fallEnd = (year: number) => new Date(year, 11, 31, 23, 59, 59, 999); + // Atlanta time, whatever the machine's zone: CI runs in UTC. + const springEnd = (year: number) => + new Date(`${year}-05-31T23:59:59.999-04:00`); + const fallEnd = (year: number) => + new Date(`${year}-12-31T23:59:59.999-05:00`); it("runs a January date out at the end of May", () => { - expect(semesterEndDate(new Date(2026, 0, 15))).toEqual(springEnd(2026)); + expect(semesterEndDate(new Date("2026-01-15T00:00:00-05:00"))).toEqual(springEnd(2026)); }); - it("runs a date one millisecond before the spring boundary out at that boundary", () => { - const justBefore = new Date(2026, 4, 31, 23, 59, 59, 998); - expect(semesterEndDate(justBefore)).toEqual(springEnd(2026)); + // The last six hours belong to the next semester: buying then would + // otherwise buy a few hours. + it("runs a date just outside the last six hours of spring out at that boundary", () => { + const outside = new Date("2026-05-31T17:00:00-04:00"); + expect(semesterEndDate(outside)).toEqual(springEnd(2026)); + }); + + it("moves a date in the last six hours of spring on to fall", () => { + const justBefore = new Date("2026-05-31T23:59:59.998-04:00"); + expect(semesterEndDate(justBefore)).toEqual(fallEnd(2026)); }); /** @@ -354,25 +373,25 @@ describe("semesterEndDate — boundaries", () => { }); it("runs a summer date out at the end of December", () => { - expect(semesterEndDate(new Date(2026, 6, 4))).toEqual(fallEnd(2026)); + expect(semesterEndDate(new Date("2026-07-04T00:00:00-04:00"))).toEqual(fallEnd(2026)); }); it("moves to next spring when given the fall boundary exactly", () => { expect(semesterEndDate(fallEnd(2026))).toEqual(springEnd(2027)); }); - it("runs a date one millisecond before the fall boundary out at that boundary", () => { - const justBefore = new Date(2026, 11, 31, 23, 59, 59, 998); - expect(semesterEndDate(justBefore)).toEqual(fallEnd(2026)); + it("moves a date in the last six hours of fall on to next spring", () => { + const justBefore = new Date("2026-12-31T23:59:59.998-05:00"); + expect(semesterEndDate(justBefore)).toEqual(springEnd(2027)); }); it("never returns a date at or before the one it was given", () => { const samples = [ - new Date(2026, 0, 1), - new Date(2026, 4, 31, 12, 0, 0), - new Date(2026, 5, 1), - new Date(2026, 11, 31, 23, 59, 59, 999), - new Date(2028, 1, 29), + new Date("2026-01-01T00:00:00-05:00"), + new Date("2026-05-31T12:00:00-04:00"), + new Date("2026-06-01T00:00:00-04:00"), + new Date("2026-12-31T23:59:59.999-05:00"), + new Date("2028-02-29T00:00:00-05:00"), ]; for (const from of samples) { @@ -380,7 +399,25 @@ describe("semesterEndDate — boundaries", () => { } }); + // The server's UTC midnight is 8pm here: a semester used to end then. + it("ends spring at midnight in Atlanta, not in UTC", () => { + expect( + semesterEndDate(new Date("2026-03-01T12:00:00-05:00")).toISOString(), + ).toBe("2026-06-01T03:59:59.999Z"); + }); + + // Memberships written before the boundary was Eastern end on the UTC one. + // Renewing from there must buy the next semester, not the missing hours. + it("renews a membership that ended on the old UTC boundary into the next semester", () => { + expect(semesterEndDate(new Date("2026-05-31T23:59:59.999Z"))).toEqual( + fallEnd(2026), + ); + expect(semesterEndDate(new Date("2026-12-31T23:59:59.999Z"))).toEqual( + springEnd(2027), + ); + }); + it("lands on a leap year's spring boundary correctly", () => { - expect(semesterEndDate(new Date(2028, 1, 29))).toEqual(springEnd(2028)); + expect(semesterEndDate(new Date("2028-02-29T00:00:00-05:00"))).toEqual(springEnd(2028)); }); }); diff --git a/packages/db/src/services/membership.test.ts b/packages/db/src/services/membership.test.ts index 3e0bef9f..ad649ddc 100644 --- a/packages/db/src/services/membership.test.ts +++ b/packages/db/src/services/membership.test.ts @@ -247,29 +247,29 @@ describe("compareTerms", () => { describe("semesterEndDate", () => { it("runs spring out at the end of May", () => { - expect(semesterEndDate(new Date("2026-02-10T12:00:00"))).toEqual( - new Date(2026, 4, 31, 23, 59, 59, 999), + expect(semesterEndDate(new Date("2026-02-10T12:00:00-05:00"))).toEqual( + new Date("2026-05-31T23:59:59.999-04:00"), ); }); it("runs fall out at the end of December", () => { - expect(semesterEndDate(new Date("2026-09-03T12:00:00"))).toEqual( - new Date(2026, 11, 31, 23, 59, 59, 999), + expect(semesterEndDate(new Date("2026-09-03T12:00:00-04:00"))).toEqual( + new Date("2026-12-31T23:59:59.999-05:00"), ); }); // Summer sells fall, the same boundary currentTerm draws. it("sells fall over the summer", () => { - expect(semesterEndDate(new Date("2026-06-20T12:00:00"))).toEqual( - new Date(2026, 11, 31, 23, 59, 59, 999), + expect(semesterEndDate(new Date("2026-06-20T12:00:00-04:00"))).toEqual( + new Date("2026-12-31T23:59:59.999-05:00"), ); }); // Otherwise renewing on the last day of a term buys nothing. it("never returns a date that has already passed", () => { - const fallEnd = new Date(2026, 11, 31, 23, 59, 59, 999); + const fallEnd = new Date("2026-12-31T23:59:59.999-05:00"); expect(semesterEndDate(fallEnd)).toEqual( - new Date(2027, 4, 31, 23, 59, 59, 999), + new Date("2027-05-31T23:59:59.999-04:00"), ); }); }); diff --git a/packages/db/src/services/membership.ts b/packages/db/src/services/membership.ts index 341c2600..e3475ac2 100644 --- a/packages/db/src/services/membership.ts +++ b/packages/db/src/services/membership.ts @@ -74,12 +74,25 @@ export async function resolveCurrentHackathonId( return resolved?.id; } +// Year and month in Atlanta. The server runs in UTC, where 8pm Eastern on +// May 31 is already June and would file an evening purchase under fall. +const easternYearMonth = (date: Date) => { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: "America/New_York", + year: "numeric", + month: "numeric", + }).formatToParts(date); + const part = (type: "year" | "month") => + Number(parts.find((p) => p.type === type)?.value); + return { year: part("year"), month: part("month") - 1 }; +}; + // Which bootcamp a purchase made today buys into. A membership is a year and // a bootcamp is a semester, so they cannot share an expiry. Summer sells fall. -export const currentTerm = (now = new Date()) => - now.getMonth() <= 4 - ? `${now.getFullYear()}-spring` - : `${now.getFullYear()}-fall`; +export const currentTerm = (now = new Date()) => { + const { year, month } = easternYearMonth(now); + return month <= 4 ? `${year}-spring` : `${year}-fall`; +}; /** Chronological order for `YYYY-spring` / `YYYY-fall` labels. Locale compare puts fall first. */ export const compareTerms = (a: string, b: string) => { @@ -116,17 +129,28 @@ export const planFromMetadata = ( // The end of the semester a date falls in — spring at the end of May, fall at // the end of December, the same boundary currentTerm draws. Always strictly // after the date given, so renewing early lands on the next semester's end. +// Ends at 23:59:59.999 in Atlanta, not on the server's UTC clock, which cut a +// semester off about four hours early. May 31 always falls in daylight time +// and Dec 31 in standard time, so each boundary has one fixed offset. export const semesterEndDate = (from = new Date()) => { - const endOf = (year: number, month: number, day: number) => - new Date(year, month, day, 23, 59, 59, 999); - - const year = from.getFullYear(); - const springEnd = endOf(year, 4, 31); // May 31 - const fallEnd = endOf(year, 11, 31); // Dec 31 - - if (from < springEnd) return springEnd; - if (from < fallEnd) return fallEnd; - return endOf(year + 1, 4, 31); + const springEndOf = (year: number) => + new Date(`${year}-05-31T23:59:59.999-04:00`); + const fallEndOf = (year: number) => + new Date(`${year}-12-31T23:59:59.999-05:00`); + + const { year } = easternYearMonth(from); + const springEnd = springEndOf(year); + const fallEnd = fallEndOf(year); + + // The last six hours of a semester count as the next one. Renewals start + // from the stored end date, and memberships written before this was Eastern + // end on the UTC boundary, four or five hours short of these: without the + // margin, renewing one bought those few hours instead of a semester. It also + // keeps a purchase on the last evening from buying a few hours. + const margin = 6 * 60 * 60 * 1000; + if (from.getTime() < springEnd.getTime() - margin) return springEnd; + if (from.getTime() < fallEnd.getTime() - margin) return fallEnd; + return springEndOf(year + 1); }; // Whether a stored payment's metadata says the bootcamp add-on was bought. diff --git a/sites/hacklytics2027/app/globals.css b/sites/hacklytics2027/app/globals.css index 2cbdc98a..f402b724 100644 --- a/sites/hacklytics2027/app/globals.css +++ b/sites/hacklytics2027/app/globals.css @@ -122,11 +122,12 @@ body { border-radius: 1px; } -/* ─── Section anchor offset for fixed navbar ─── */ +/* ─── Section anchor offset for fixed navbar ─── + No content-visibility here: skipped sections sat at a 600px placeholder, so + a nav tap or a /#faqs link computed its target from heights that changed + mid-scroll and landed off the section. */ .section-anchor { scroll-margin-top: calc(var(--navbar-height) + 1.5rem); - content-visibility: auto; - contain-intrinsic-size: auto 600px; } /* ─── Pixel flora — Terraria-style sprite layer ─── */ diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx index 5c643948..ac56009b 100644 --- a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx +++ b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx @@ -161,6 +161,38 @@ export default function JudgeHackathonPage() { setCurrent({ project, queueId }); }; + /** + * The slot on screen is gone: an organiser withdrew the project, which + * deletes its unscored slots. Every action on the card would fail the same + * way, so fetch the next table instead of leaving the judge on a dead card. + * Only these answers mean that; anything else (judging closed) keeps the + * card and the scores typed into it. + */ + const recoverOrReport = (e: { + message: string; + data?: { code?: string } | null; + }) => { + const slotGone = + e.data?.code === "NOT_FOUND" || + (e.data?.code === "FORBIDDEN" && + e.message.includes("not in your judging queue")); + if (!slotGone) { + setError(e.message); + return; + } + void nextTable.refetch().then(({ data }) => { + if (!data) { + setError(e.message); + return; + } + advance( + data.done ? null : ((data.project as Project) ?? null), + data.queueId ?? null, + ); + setError("That table was withdrawn by an organiser. Here is your next one."); + }); + }; + // Neither mutation invalidates getNextTable: refetching it would claim a // table a second time, and both already return the next project to show. const complete = trpc.judge.completeAndNext.useMutation({ @@ -171,7 +203,7 @@ export default function JudgeHackathonPage() { } advance((res.nextProject as Project) ?? null, res.nextQueueId ?? null); }, - onError: (e) => setError(e.message), + onError: recoverOrReport, }); /** @@ -201,7 +233,7 @@ export default function JudgeHackathonPage() { setStranded(res.skippedToEnd === true); advance((res.project as Project) ?? null, res.queueId ?? null); }, - onError: (e) => setError(e.message), + onError: recoverOrReport, }); // The escape from a table nobody is standing at: marks it done without a @@ -216,7 +248,7 @@ export default function JudgeHackathonPage() { } advance((res.project as Project) ?? null, res.queueId ?? null); }, - onError: (e) => setError(e.message), + onError: recoverOrReport, }); useEffect(() => { diff --git a/sites/mainweb/components/portal/ModalWrapper.tsx b/sites/mainweb/components/portal/ModalWrapper.tsx index 4175e053..5f23c299 100644 --- a/sites/mainweb/components/portal/ModalWrapper.tsx +++ b/sites/mainweb/components/portal/ModalWrapper.tsx @@ -32,11 +32,20 @@ export function ModalWrapper({ }: ModalWrapperProps) { const panelRef = useRef(null); + // Read through a ref so the effect below runs once per open. Keyed on + // onClose, a caller whose handler changes identity (one that depends on a + // pending flag) ran the cleanup mid-dialog: focus went back to the page and + // then jumped to the panel, out of whatever control the user was in. + const onCloseRef = useRef(onClose); + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + useEffect(() => { const returnFocusTo = document.activeElement as HTMLElement | null; const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); + if (e.key === "Escape") onCloseRef.current(); }; document.addEventListener("keydown", onKeyDown); @@ -53,7 +62,7 @@ export function ModalWrapper({ document.body.style.overflow = previousOverflow; returnFocusTo?.focus?.(); }; - }, [onClose]); + }, []); return (
diff --git a/sites/mainweb/lib/chunk-error.ts b/sites/mainweb/lib/chunk-error.ts index 2d335635..a89c5961 100644 --- a/sites/mainweb/lib/chunk-error.ts +++ b/sites/mainweb/lib/chunk-error.ts @@ -23,9 +23,17 @@ export function isChunkLoadError(error: unknown): boolean { const RELOAD_FLAG = "chunk-reload-attempted"; +/** + * A second failure this soon after reloading means the chunk really is gone, + * not stale. Stored as a time rather than a flag: a flag was cleared only when + * an error boundary rendered something else, so after one reload the tab never + * recovered from a later deploy. + */ +const RELOAD_LOOP_WINDOW_MS = 30_000; + /** * Reloads once when the boundary caught a stale-chunk error. The sessionStorage - * flag stops a genuinely-missing chunk from causing an endless reload loop. + * timestamp stops a genuinely-missing chunk from causing an endless reload loop. */ export function useChunkErrorRecovery(error: unknown): boolean { const isChunkError = isChunkLoadError(error); @@ -43,8 +51,10 @@ export function useChunkErrorRecovery(error: unknown): boolean { let alreadyTried = false; try { - alreadyTried = sessionStorage.getItem(RELOAD_FLAG) === "1"; - sessionStorage.setItem(RELOAD_FLAG, "1"); + const lastReload = Number(sessionStorage.getItem(RELOAD_FLAG)); + alreadyTried = + lastReload > 0 && Date.now() - lastReload < RELOAD_LOOP_WINDOW_MS; + sessionStorage.setItem(RELOAD_FLAG, String(Date.now())); } catch { // If storage is unavailable, fall through and reload once. } From b7979a9b030e6088ed7d4c4b455323b29ae9fbc1 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Wed, 30 Sep 2026 15:52:27 -0400 Subject: [PATCH 2/3] fix: close remaining audit gaps in submissions, waves and metrics - hackathon_projects gets withdrawn_by_admin_at (additive). An organiser pulling a project before judging left no judging row, so the team could resubmit it straight back into the gallery; submitProject now refuses on the column too. - submitProject bounds name, description and list sizes, and keeps only tracks and challenges the hackathon offers. Unknown values are dropped rather than refused: the form cannot untick a track removed since. - acceptWave locks the hackathon row, so two concurrent waves get different numbers instead of both claiming max + 1. - The metrics term label reads Eastern time, matching currentTerm. --- .../api/src/.internal-tests/routers.test.ts | 56 +++++++++++++++++++ packages/api/src/routers/hackathon/admin.ts | 9 +++ packages/api/src/routers/hackathon/content.ts | 7 ++- packages/api/src/routers/team.ts | 41 +++++++++++--- packages/api/src/services/metrics.ts | 20 +++++-- packages/db/src/schemas/hackathons.ts | 3 + 6 files changed, 121 insertions(+), 15 deletions(-) diff --git a/packages/api/src/.internal-tests/routers.test.ts b/packages/api/src/.internal-tests/routers.test.ts index 2b21118b..c10cf73c 100644 --- a/packages/api/src/.internal-tests/routers.test.ts +++ b/packages/api/src/.internal-tests/routers.test.ts @@ -837,6 +837,62 @@ describe("Router Integration and Access Control Verification Suite", () => { ); }); + // An organiser pulled it before judging: no judging row exists, so only + // the column on the project stops it going straight back into the gallery. + it("refuses to resubmit a project an organiser withdrew", async () => { + const ctx = createMockCtx("captain_user_id"); + const hackathonId = "00000000-0000-4000-8000-000000000001"; + const teamId = "00000000-0000-4000-8000-000000000002"; + + const startDate20hAgo = new Date(Date.now() - 20 * 60 * 60 * 1000); // inside the edit window + + mockFindFirst.mockImplementation((table) => { + if (table === "hackathonParticipants") { + return { + id: "participant_1", + userId: "captain_user_id", + hackathonId, + teamId, + // Submitting requires the badge scan; these tests are about the + // window, so admission is deliberately out of the way. + registrationStatus: "checked_in", + }; + } + if (table === "hackathons") { + return { + id: hackathonId, + startDate: startDate20hAgo, + hackingStartTime: null, + }; + } + if (table === "hackathonTeams") { + return { id: teamId, captainId: "captain_user_id", hackathonId }; + } + if (table === "hackathonProjects") { + return { + id: "project_1", + hackathonId, + teamId, + name: "Old Name", + description: "Old Description", + status: "draft", + withdrawnByAdminAt: new Date(), + }; + } + return null; + }); + + const caller = appRouter.createCaller(ctx); + await expect( + caller.team.submitProject({ + hackathonId, + teamId, + name: "Awesome Project", + description: "This is a long description of the awesome project.", + }), + ).rejects.toThrowError(/organiser withdrew this project/); + }); + it("should prevent project submissions more than 36 hours after hacking starts", async () => { const ctx = createMockCtx("captain_user_id"); const hackathonId = "00000000-0000-4000-8000-000000000001"; diff --git a/packages/api/src/routers/hackathon/admin.ts b/packages/api/src/routers/hackathon/admin.ts index 9b84d1f9..b2e2fd88 100644 --- a/packages/api/src/routers/hackathon/admin.ts +++ b/packages/api/src/routers/hackathon/admin.ts @@ -340,6 +340,15 @@ export const hackathonAdminRouter = createTRPCRouter({ } const { wave, picked } = await db.transaction(async (tx) => { + // Serialises waves for this hackathon. SKIP LOCKED below keeps two + // concurrent waves from picking the same people, but both still read + // the same max and were recorded as one wave number. + await tx + .select({ id: hackathons.id }) + .from(hackathons) + .where(eq(hackathons.id, input.hackathonId)) + .for("update"); + const [highest] = await tx .select({ max: sql< diff --git a/packages/api/src/routers/hackathon/content.ts b/packages/api/src/routers/hackathon/content.ts index 620d0e45..53b14158 100644 --- a/packages/api/src/routers/hackathon/content.ts +++ b/packages/api/src/routers/hackathon/content.ts @@ -103,7 +103,12 @@ export const hackathonContentRouter = createTRPCRouter({ await db .update(hackathonProjects) - .set({ status: "draft", submittedAt: null, updatedAt: new Date() }) + .set({ + status: "draft", + submittedAt: null, + withdrawnByAdminAt: new Date(), + updatedAt: new Date(), + }) .where(eq(hackathonProjects.id, input.projectId)); // The judging entry has to go with it, or the CONFLICT message above is a diff --git a/packages/api/src/routers/team.ts b/packages/api/src/routers/team.ts index 1a4b250c..37491328 100644 --- a/packages/api/src/routers/team.ts +++ b/packages/api/src/routers/team.ts @@ -673,13 +673,18 @@ export const teamRouter = createTRPCRouter({ z.object({ hackathonId: z.string().uuid(), teamId: z.string().uuid().optional(), // Can be solo - name: z.string().min(1, "Project name is required"), + name: z + .string() + .trim() + .min(1, "Project name is required") + .max(120, "Project name must be 120 characters or fewer"), description: z .string() - .min(10, "Description must be at least 10 characters"), - technologies: z.array(z.string()).optional(), - tracks: z.array(z.string()).optional(), - challenges: z.array(z.string()).optional(), + .min(10, "Description must be at least 10 characters") + .max(5000, "Description must be 5,000 characters or fewer"), + technologies: z.array(z.string().max(60)).max(30).optional(), + tracks: z.array(z.string().max(100)).max(20).optional(), + challenges: z.array(z.string().max(100)).max(20).optional(), // Judge routing filters on exactly this, and nothing else ever set it — every // CreateX judge got an empty pool. isCreateX: z.boolean().optional(), @@ -742,6 +747,17 @@ export const teamRouter = createTRPCRouter({ }); } + // Judge routing matches on these strings, so only what this hackathon + // offers is kept. Dropped rather than refused: the form pre-fills a saved + // project's tracks but only shows current ones, so a track removed since + // could not be unticked and every edit would fail. + const offered = (allowed: string[] | null, picked?: string[]) => + picked && allowed?.length + ? picked.filter((value) => allowed.includes(value)) + : picked; + input.tracks = offered(hackathon.tracks, input.tracks); + input.challenges = offered(hackathon.challenges, input.challenges); + const now = new Date(); const baseTime = hackathon.hackingStartTime ?? hackathon.startDate; const window = computeSubmissionWindow(baseTime, now); @@ -790,9 +806,18 @@ export const teamRouter = createTRPCRouter({ }); } - // A team can only withdraw before judging has the project, so a withdrawn - // judging entry means an organiser pulled it. Re-saving would put it back - // in the gallery as submitted while judging keeps ignoring it. + // An organiser pulled it: re-saving would put it back in the gallery. + // The column covers a pull before judging; the judging row covers + // pulls made before the column existed (a team can only withdraw + // before judging has the project, so a withdrawn judging entry means + // an organiser did it). + if (existingProject?.withdrawnByAdminAt) { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "An organiser withdrew this project, so it can no longer be resubmitted.", + }); + } if (existingProject) { const pulled = await tx.query.judgingProjects.findFirst({ where: and( diff --git a/packages/api/src/services/metrics.ts b/packages/api/src/services/metrics.ts index acbc420f..f73541b0 100644 --- a/packages/api/src/services/metrics.ts +++ b/packages/api/src/services/metrics.ts @@ -167,12 +167,20 @@ class GaugeCache { const gaugeCache = new GaugeCache(); // `2026-fall` — duplicated from @query/db rather than imported, because this -// module is pulled into the metrics route and the rule is three lines. If the -// two disagree the gauge is mislabelled, nothing more. -const currentTermLabel = (now = new Date()) => - now.getMonth() <= 4 - ? `${now.getFullYear()}-spring` - : `${now.getFullYear()}-fall`; +// module is pulled into the metrics route and the rule is a few lines. Read in +// Eastern time like currentTerm, or the label flips four hours early at the +// May and December boundaries. If the two disagree the gauge is mislabelled, +// nothing more. +const currentTermLabel = (now = new Date()) => { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: "America/New_York", + year: "numeric", + month: "numeric", + }).formatToParts(now); + const year = parts.find((p) => p.type === "year")?.value; + const month = Number(parts.find((p) => p.type === "month")?.value); + return month <= 5 ? `${year}-spring` : `${year}-fall`; +}; // Recomputes the database-derived gauges, at most once a minute. Every query // is a count behind an index-friendly predicate, and a failure leaves the diff --git a/packages/db/src/schemas/hackathons.ts b/packages/db/src/schemas/hackathons.ts index 6fcf4358..64d0c602 100644 --- a/packages/db/src/schemas/hackathons.ts +++ b/packages/db/src/schemas/hackathons.ts @@ -244,6 +244,9 @@ export const hackathonProjects = pgTable( score: integer("score"), ranking: integer("ranking"), submittedAt: timestamp("submitted_at"), + // Set when an organiser pulls the project. A team's own withdrawal leaves + // it null, which is how submitProject tells the two drafts apart. + withdrawnByAdminAt: timestamp("withdrawn_by_admin_at"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, From 07116998fe69746fb66ad47200b5398122d5e003 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 1 Oct 2026 11:14:43 -0400 Subject: [PATCH 3/3] fix(judge): do not fall back to the withdrawn table on a failed refetch A failed getNextTable refetch still resolves with the cached answer, which is the withdrawn table itself, so a network blip put the judge back on the dead card under a message saying they had a new one. An error result, or one naming the same slot, now reports the failure; pressing any card action again retries. --- .../app/(portal)/hackathons/[id]/judge/page.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx index ac56009b..3e0f28dc 100644 --- a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx +++ b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx @@ -180,9 +180,15 @@ export default function JudgeHackathonPage() { setError(e.message); return; } - void nextTable.refetch().then(({ data }) => { - if (!data) { - setError(e.message); + const goneQueueId = current?.queueId; + void nextTable.refetch().then(({ data, isError }) => { + // A failed refetch still resolves, carrying the cached answer — the + // withdrawn table itself. Moving to it would put the judge back on the + // dead card under a message saying they had a new one. + if (isError || !data || (!data.done && data.queueId === goneQueueId)) { + setError( + "That table was withdrawn by an organiser, and your next one could not be loaded. Check your connection and try again.", + ); return; } advance(