From ec0fe811635d05807792f300d5b65517f6ec4b83 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Mon, 10 Aug 2026 19:13:30 -0400 Subject: [PATCH 01/12] go --- .../src/.internal-tests/judge-edge.test.ts | 153 ++++++++++++++++- packages/api/src/routers/initiative.ts | 4 +- packages/api/src/routers/judge/admin.ts | 159 +++++++++++++++++- packages/api/src/services/portal-context.ts | 23 ++- packages/db/src/schemas/stripe.ts | 5 + .../app/(portal)/admin/analytics/page.tsx | 20 +-- .../app/(portal)/admin/attendees/page.tsx | 13 +- .../(portal)/admin/hackathons/[id]/page.tsx | 10 +- .../app/(portal)/admin/hackathons/loading.tsx | 2 +- .../app/(portal)/admin/hackathons/page.tsx | 7 +- .../app/(portal)/admin/initiatives/page.tsx | 4 +- .../app/(portal)/admin/judging/page.tsx | 115 ++++++++++++- sites/mainweb/app/(portal)/admin/page.tsx | 22 ++- .../app/(portal)/admin/projects/page.tsx | 11 +- .../mainweb/app/(portal)/admin/setup/page.tsx | 12 +- .../mainweb/app/(portal)/admin/staff/page.tsx | 4 +- .../mainweb/app/(portal)/auth/error/page.tsx | 4 +- sites/mainweb/app/(portal)/club/page.tsx | 18 +- sites/mainweb/app/(portal)/dashboard/page.tsx | 28 +-- .../(portal)/hackathons/[id]/judge/page.tsx | 20 +-- .../app/(portal)/hackathons/[id]/loading.tsx | 2 +- .../app/(portal)/hackathons/[id]/page.tsx | 8 +- .../app/(portal)/hackathons/loading.tsx | 2 +- .../mainweb/app/(portal)/hackathons/page.tsx | 26 +-- .../mainweb/app/(portal)/hacklytics/page.tsx | 16 +- .../mainweb/app/(portal)/initiatives/page.tsx | 4 +- sites/mainweb/app/(portal)/judge/page.tsx | 4 +- .../app/(portal)/judge/register/page.tsx | 16 +- sites/mainweb/app/(portal)/lead/page.tsx | 2 +- sites/mainweb/app/(portal)/login/page.tsx | 18 +- sites/mainweb/app/(portal)/scan/page.tsx | 6 +- sites/mainweb/app/(portal)/settings/page.tsx | 44 +++-- sites/mainweb/app/(portal)/submit/loading.tsx | 2 +- sites/mainweb/app/(portal)/submit/page.tsx | 65 +++---- sites/mainweb/app/(portal)/verify/page.tsx | 13 +- sites/mainweb/app/globals.css | 74 ++++++++ sites/mainweb/components/Hero/index.tsx | 2 +- sites/mainweb/components/Navbar/index.tsx | 14 +- sites/mainweb/components/TeamCard/index.tsx | 6 +- .../admin/hackathons/AnnouncementsTab.tsx | 4 +- .../admin/hackathons/AttendeesTab.tsx | 8 +- .../admin/hackathons/CreateHackathonForm.tsx | 2 +- .../admin/hackathons/EditHackathonForm.tsx | 2 +- .../components/admin/hackathons/EventsTab.tsx | 6 +- .../admin/hackathons/HackathonCard.tsx | 2 +- .../admin/hackathons/JudgeLiveBoard.tsx | 148 ++++++++++++++++ .../components/admin/hackathons/JudgesTab.tsx | 8 +- .../admin/hackathons/TableCards.tsx | 29 ++-- .../admin/setup/CreateHackathonStep.tsx | 2 +- .../components/hackathon/FormComponents.tsx | 2 +- .../mainweb/components/hackathon/InfoTab.tsx | 14 +- .../mainweb/components/hackathon/TeamsTab.tsx | 6 +- .../components/portal/ClubScannerTab.tsx | 4 +- .../portal/EventAttendanceModal.tsx | 4 +- .../components/portal/EventFormModal.tsx | 4 +- .../components/portal/LinkStripeAccount.tsx | 2 + .../components/portal/LoadingScreen.tsx | 24 ++- .../components/portal/MemberPassCard.tsx | 2 +- .../components/portal/ModalWrapper.tsx | 51 +++++- .../components/portal/PortalSidebar.tsx | 31 ++-- .../mainweb/components/portal/QRCodeModal.tsx | 2 +- .../components/portal/StripePaymentModal.tsx | 2 +- 62 files changed, 1029 insertions(+), 288 deletions(-) create mode 100644 sites/mainweb/components/admin/hackathons/JudgeLiveBoard.tsx diff --git a/packages/api/src/.internal-tests/judge-edge.test.ts b/packages/api/src/.internal-tests/judge-edge.test.ts index 7933a525..0bc8e50b 100644 --- a/packages/api/src/.internal-tests/judge-edge.test.ts +++ b/packages/api/src/.internal-tests/judge-edge.test.ts @@ -232,7 +232,10 @@ const JUDGE_ROW = { isActive: true, name: "Grace Hopper", }; -const ADMIN_ROW = { userId: "admin_user", isActive: true, role: "admin" }; +// Super admin, because activating and deactivating a judge is restricted to +// that tier. It still passes isAdmin, so the rest of the suite is unaffected. +const ADMIN_ROW = { userId: "admin_user", isActive: true, role: "super_admin" }; +const PLAIN_ADMIN_ROW = { userId: "admin_user", isActive: true, role: "admin" }; /** Returns successive elements of `items`, then undefined forever. */ const seq = (items: unknown[]) => { @@ -1244,6 +1247,26 @@ describe("Judge edge cases", () => { expect(mockDelete).not.toHaveBeenCalled(); }); + /** + * Activating and deactivating a person is the super-admin tier. A plain + * admin runs the event; deciding who holds a role does not come with that. + */ + it("refuses a plain admin, and writes nothing", async () => { + mockFindFirst.mockImplementation((table: string) => + table === "admins" ? PLAIN_ADMIN_ROW : undefined, + ); + + await expect( + adminCaller().judge.setActive({ judgeId: JUDGE_ID, isActive: true }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + await expect( + adminCaller().judge.remove({ judgeId: JUDGE_ID }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + expect(mockUpdate).not.toHaveBeenCalled(); + expect(mockDelete).not.toHaveBeenCalled(); + }); + // isJudge caches the judges row for 60s under // `judge:::role` (procedures.ts:97-110). Nothing in // admin.ts clears that key explicitly — the protection comes from @@ -1928,4 +1951,132 @@ describe("Judge edge cases", () => { expect(mockUpdate).not.toHaveBeenCalled(); }); }); + + // ===================================================================== + /** + * The floor view. Every column it reads was already stored; nothing put it + * in one place, so finding a stalled judge on the day meant walking over to + * look at them. + */ + describe("10. Live judge progress", () => { + const MIN = 60 * 1000; + + /** queueRows first, then voteRows — the order liveProgress selects them. */ + const wireFloor = (queueRows: unknown[], voteRows: unknown[]) => { + mockFindFirst.mockImplementation((table: string) => + table === "admins" ? ADMIN_ROW : undefined, + ); + mockSelect.mockReset(); + mockSelect + .mockReturnValueOnce(queueRows) + .mockReturnValueOnce(voteRows) + .mockReturnValue([]); + }; + + const slot = (over: Record = {}) => ({ + judgeId: JUDGE_ID, + judgeName: "Ada", + judgeEmail: "ada@example.com", + isActive: true, + isCompleted: false, + startedAt: null, + completedAt: null, + order: 1, + tableNumber: 7, + projectName: "Flood Mapper", + ...over, + }); + + it("reports a judge who has a queue and has scored nothing", async () => { + wireFloor([slot()], []); + + const res = await adminCaller().judge.liveProgress({ + hackathonId: HACK_A, + }); + + expect(res.judges[0]).toMatchObject({ + status: "not_started", + assigned: 1, + completed: 0, + scored: 0, + }); + expect(res.totals).toMatchObject({ assigned: 1, completed: 0, percent: 0 }); + }); + + it("shows which table a judge is standing at, and for how long", async () => { + wireFloor( + [slot({ startedAt: new Date(Date.now() - 8 * MIN) })], + [{ judgeId: JUDGE_ID, votedAt: new Date(), durationSeconds: 300 }], + ); + + const res = await adminCaller().judge.liveProgress({ + hackathonId: HACK_A, + }); + + expect(res.judges[0]).toMatchObject({ status: "judging" }); + expect(res.judges[0]!.current).toMatchObject({ + tableNumber: 7, + onItMinutes: 8, + }); + }); + + it("counts idle minutes from the last vote, not from the queue", async () => { + wireFloor( + [slot({ isCompleted: true, completedAt: new Date() }), slot({ order: 2 })], + [ + { + judgeId: JUDGE_ID, + votedAt: new Date(Date.now() - 25 * MIN), + durationSeconds: 240, + }, + ], + ); + + const res = await adminCaller().judge.liveProgress({ + hackathonId: HACK_A, + }); + + expect(res.judges[0]).toMatchObject({ status: "between", idleMinutes: 25 }); + }); + + it("reports a finished judge as done, at 100 percent", async () => { + wireFloor( + [slot({ isCompleted: true, completedAt: new Date() })], + [{ judgeId: JUDGE_ID, votedAt: new Date(), durationSeconds: 200 }], + ); + + const res = await adminCaller().judge.liveProgress({ + hackathonId: HACK_A, + }); + + expect(res.judges[0]).toMatchObject({ status: "done" }); + expect(res.totals.percent).toBe(100); + }); + + it("puts the judge who has not started above the one who has finished", async () => { + wireFloor( + [ + slot({ isCompleted: true, completedAt: new Date() }), + slot({ judgeId: "judge_b", judgeName: "Grace", order: 1 }), + ], + [{ judgeId: JUDGE_ID, votedAt: new Date(), durationSeconds: 200 }], + ); + + const res = await adminCaller().judge.liveProgress({ + hackathonId: HACK_A, + }); + + expect(res.judges.map((j) => j.status)).toEqual(["not_started", "done"]); + }); + + it("is refused to somebody who is not staff", async () => { + mockFindFirst.mockImplementation(() => undefined); + + await expect( + appRouter + .createCaller(ctxFor("random_user")) + .judge.liveProgress({ hackathonId: HACK_A }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + }); }); \ No newline at end of file diff --git a/packages/api/src/routers/initiative.ts b/packages/api/src/routers/initiative.ts index 430ee700..e01398e1 100644 --- a/packages/api/src/routers/initiative.ts +++ b/packages/api/src/routers/initiative.ts @@ -10,7 +10,7 @@ import { } from "@query/db"; import type { DrizzleDB, Initiative } from "@query/db"; import { createTRPCRouter, protectedProcedure } from "../trpc"; -import { isAdmin, isProjectLeader } from "../middleware/procedures"; +import { isAdmin, isSuperAdmin, isProjectLeader } from "../middleware/procedures"; import { clearProjectLeaderCaches } from "../middleware/cache"; const notFound = (message = "Initiative not found") => @@ -1038,7 +1038,7 @@ export const initiativeRouter = createTRPCRouter({ * Grant or revoke, by user id. Upserted rather than deleted so an * appointment stays on the record after it is revoked. */ - setLeader: isAdmin + setLeader: isSuperAdmin .input(z.object({ userId: z.string(), isLeader: z.boolean() })) .mutation(async ({ ctx, input }) => { const db = ctx.db as DrizzleDB; diff --git a/packages/api/src/routers/judge/admin.ts b/packages/api/src/routers/judge/admin.ts index c6660fd2..aa76fdf1 100644 --- a/packages/api/src/routers/judge/admin.ts +++ b/packages/api/src/routers/judge/admin.ts @@ -13,7 +13,7 @@ import { hackathonParticipants, } from "@query/db"; import { eq, and, asc, sql, inArray, isNull } from "drizzle-orm"; -import { isAdmin } from "../../middleware/procedures"; +import { isAdmin, isSuperAdmin } from "../../middleware/procedures"; import { recordAdminAction } from "../../middleware/audit"; import { CacheKeys, invalidatePortalContext } from "../../middleware/cache"; import type { DrizzleDB } from "@query/db"; @@ -702,7 +702,7 @@ export const judgeAdminRouter = createTRPCRouter({ * judge.create refuses once it exists, so without this a self-registered * judge can never be activated by any route. */ - setActive: isAdmin + setActive: isSuperAdmin .input( z.object({ judgeId: z.string().uuid(), @@ -963,7 +963,7 @@ export const judgeAdminRouter = createTRPCRouter({ }; }), - remove: isAdmin + remove: isSuperAdmin .input(z.object({ judgeId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { // judgeVotes.judgeId cascades on delete, so removing a judge who has @@ -1351,6 +1351,159 @@ export const judgeAdminRouter = createTRPCRouter({ }), /** Per-judge scoring analytics for bias detection and performance review. */ + /** + * Where every judge is, right now. + * + * All of this was already stored — queue order, the claim stamp, the arrival + * stamp, vote times and durations — and nothing put it in one place, so on + * the day the only way to find a stalled judge was to go and look at them. + * + * Deliberately uncached: it is read on a short poll while judging runs, and + * a stale answer here is worse than no answer. + */ + liveProgress: isAdmin + .input(z.object({ hackathonId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const now = new Date(); + + const [queueRows, voteRows] = await Promise.all([ + db + .select({ + judgeId: judgeQueue.judgeId, + judgeName: judges.name, + judgeEmail: judges.email, + isActive: judges.isActive, + isCompleted: judgeQueue.isCompleted, + startedAt: judgeQueue.startedAt, + completedAt: judgeQueue.completedAt, + order: judgeQueue.order, + tableNumber: judgingProjects.tableNumber, + projectName: judgingProjects.name, + }) + .from(judgeQueue) + .innerJoin(judges, eq(judges.id, judgeQueue.judgeId)) + .leftJoin( + judgingProjects, + eq(judgingProjects.id, judgeQueue.projectId), + ) + .where(eq(judgeQueue.hackathonId, input.hackathonId)) + .orderBy(asc(judgeQueue.order)), + db + .select({ + judgeId: judgeVotes.judgeId, + votedAt: judgeVotes.votedAt, + durationSeconds: judgeVotes.durationSeconds, + }) + .from(judgeVotes) + .innerJoin( + judgingProjects, + and( + eq(judgingProjects.id, judgeVotes.projectId), + eq(judgingProjects.hackathonId, input.hackathonId), + ), + ), + ]); + + const votesByJudge = new Map(); + for (const v of voteRows) { + const list = votesByJudge.get(v.judgeId) ?? []; + list.push(v); + votesByJudge.set(v.judgeId, list); + } + + const byJudge = new Map(); + for (const row of queueRows) { + const list = byJudge.get(row.judgeId) ?? []; + list.push(row); + byJudge.set(row.judgeId, list); + } + + const minutesSince = (d: Date | null) => + d ? Math.floor((now.getTime() - new Date(d).getTime()) / 60000) : null; + + const judgesOut = [...byJudge.entries()].map(([judgeId, rows]) => { + const first = rows[0]!; + const done = rows.filter((r) => r.isCompleted); + const votes = votesByJudge.get(judgeId) ?? []; + + // The project handed over but not yet scored. There is at most one: + // completeAndNext closes the previous row before claiming the next. + const current = rows.find((r) => !r.isCompleted && r.startedAt) ?? null; + + const lastVoteAt = votes.reduce((acc, v) => { + const at = v.votedAt ? new Date(v.votedAt) : null; + return at && (!acc || at > acc) ? at : acc; + }, null); + + const durations = votes + .map((v) => v.durationSeconds) + .filter((d): d is number => typeof d === "number" && d > 0); + + const medianSeconds = durations.length + ? [...durations].sort((a, b) => a - b)[ + Math.floor(durations.length / 2) + ]! + : null; + + const idleMinutes = minutesSince(lastVoteAt); + + const status = !first.isActive + ? ("suspended" as const) + : done.length === rows.length && rows.length > 0 + ? ("done" as const) + : current + ? ("judging" as const) + : votes.length === 0 + ? ("not_started" as const) + : ("between" as const); + + return { + judgeId, + name: first.judgeName, + email: first.judgeEmail, + status, + assigned: rows.length, + completed: done.length, + remaining: rows.length - done.length, + scored: votes.length, + medianSeconds, + idleMinutes, + current: current + ? { + tableNumber: current.tableNumber, + projectName: current.projectName, + onItMinutes: minutesSince(current.startedAt), + } + : null, + }; + }); + + // Worst first: a judge who has stopped is the reason to open this screen. + const rank = { not_started: 0, judging: 1, between: 2, done: 3, suspended: 4 }; + judgesOut.sort( + (a, b) => + rank[a.status] - rank[b.status] || + (b.idleMinutes ?? 0) - (a.idleMinutes ?? 0), + ); + + const totalAssigned = queueRows.length; + const totalCompleted = queueRows.filter((r) => r.isCompleted).length; + + return { + judges: judgesOut, + totals: { + judges: judgesOut.length, + assigned: totalAssigned, + completed: totalCompleted, + percent: + totalAssigned === 0 + ? 0 + : Math.round((totalCompleted / totalAssigned) * 100), + }, + }; + }), + getJudgeAnalytics: isAdmin .input(z.object({ hackathonId: z.string().uuid() })) .query(async ({ ctx, input }) => { diff --git a/packages/api/src/services/portal-context.ts b/packages/api/src/services/portal-context.ts index a3932199..110120ed 100644 --- a/packages/api/src/services/portal-context.ts +++ b/packages/api/src/services/portal-context.ts @@ -104,7 +104,10 @@ export async function fetchPortalContext( db: DrizzleDB, userId: string, ): Promise { - const [admin, judgeRecord, leaderRecord] = await Promise.all([ + // All four in one round trip. The member read used to run after the batch + // even though it depends on nothing in it, which made every portal page wait + // two round trips for a context it could have had in one. + const [admin, judgeRecord, leaderRecord, memberRecord] = await Promise.all([ db.query.admins.findFirst({ where: and(eq(admins.userId, userId), eq(admins.isActive, true)), }), @@ -121,13 +124,21 @@ export async function fetchPortalContext( ), columns: { id: true }, }), + // Membership no longer depends on an edition resolving, so the portal knows + // who is a member even when no hackathon is running. + db.query.members.findFirst({ + where: eq(members.userId, userId), + // buildMemberContext reads four fields; the row carries the whole + // profile, including free-text bio and skills arrays. + columns: { + isActive: true, + membershipEndDate: true, + memberType: true, + renewalCount: true, + }, + }), ]); - // Membership no longer depends on an edition resolving, so the portal knows - // who is a member even when no hackathon is running. - const memberRecord = await db.query.members.findFirst({ - where: eq(members.userId, userId), - }); const member = buildMemberContext(memberRecord ?? null); const isProjectLeader = !!leaderRecord; diff --git a/packages/db/src/schemas/stripe.ts b/packages/db/src/schemas/stripe.ts index 566d82ae..54d4a7f3 100644 --- a/packages/db/src/schemas/stripe.ts +++ b/packages/db/src/schemas/stripe.ts @@ -49,6 +49,11 @@ export const stripePayments = pgTable( (table) => [ index("stripe_payment_customer_email_idx").on(table.customerEmail), index("stripe_payment_linked_user_id_idx").on(table.linkedUserId), + // The webhook looks a payment up by intent id on every + // payment_intent.succeeded, and reconcileMyPayments does the same. Not + // unique: the column is nullable, and Postgres treats NULLs as distinct, + // which is the arbiter trap in the plan's known-traps list. + index("stripe_payment_intent_id_idx").on(table.stripePaymentIntentId), ], ); diff --git a/sites/mainweb/app/(portal)/admin/analytics/page.tsx b/sites/mainweb/app/(portal)/admin/analytics/page.tsx index af0d7e8e..9cccbd8a 100644 --- a/sites/mainweb/app/(portal)/admin/analytics/page.tsx +++ b/sites/mainweb/app/(portal)/admin/analytics/page.tsx @@ -27,7 +27,7 @@ function StatCard({ trend, }: StatCardProps) { return ( - + {/* Background gradients */}
@@ -49,7 +49,7 @@ function StatCard({ {subtitle} {trend?.positive && ( - + {trend.percent}% @@ -97,13 +97,13 @@ export default function AnalyticsPage() {
{/* Page Header - Enhanced */} -
+

Club Events

-

+

Analytics Dashboard

@@ -157,7 +157,7 @@ export default function AnalyticsPage() { {/* Charts Section - Enhanced */}

{/* Registration Trend */} - +
-

+

Registration Trend

@@ -181,7 +181,7 @@ export default function AnalyticsPage() { {/* Event Types */} - +
-

+

Event Distribution

@@ -207,7 +207,7 @@ export default function AnalyticsPage() {
{/* Recent Activity - Enhanced */} - +
-

+

Recent Activity

diff --git a/sites/mainweb/app/(portal)/admin/attendees/page.tsx b/sites/mainweb/app/(portal)/admin/attendees/page.tsx index 64110de3..896f4332 100644 --- a/sites/mainweb/app/(portal)/admin/attendees/page.tsx +++ b/sites/mainweb/app/(portal)/admin/attendees/page.tsx @@ -79,12 +79,12 @@ export default function AttendeesPage() {
{/* Page Header */} -
+

Club Events

-

+

Attendees{" "} Registry

@@ -100,11 +100,12 @@ export default function AttendeesPage() {
setSelectedHackathon(e.target.value || null)} className="bg-transparent text-[var(--text-primary)] text-sm font-medium px-4 py-2 focus:outline-none cursor-pointer" > - + {hackathonList?.map((h) => (