diff --git a/packages/api/src/.internal-tests/participant-edge.test.ts b/packages/api/src/.internal-tests/participant-edge.test.ts index 903a3875..8cc7718c 100644 --- a/packages/api/src/.internal-tests/participant-edge.test.ts +++ b/packages/api/src/.internal-tests/participant-edge.test.ts @@ -969,6 +969,9 @@ describe("Participant edge cases", () => { // includes registrationStatus, and the query has no limit. it("hides who was rejected from the public roster", async () => { mockFindMany.mockReturnValue([]); + mockFindFirst.mockImplementation((table: string) => + table === "hackathons" ? { id: HACK_A, status: "open" } : undefined, + ); await callerFor().hackathon.participants({ hackathonId: HACK_A }); diff --git a/packages/api/src/.internal-tests/qr-checkin.test.ts b/packages/api/src/.internal-tests/qr-checkin.test.ts index 26d2dac5..5b502413 100644 --- a/packages/api/src/.internal-tests/qr-checkin.test.ts +++ b/packages/api/src/.internal-tests/qr-checkin.test.ts @@ -884,6 +884,9 @@ describe("QR check-in", () => { ] : [], ); + mockFindFirst.mockImplementation((table: string) => + table === "hackathons" ? { id: HACK_A, status: "open" } : undefined, + ); const anon = appRouter.createCaller(createMockCtx()); const rows: any[] = await anon.hackathon.participants({ diff --git a/packages/api/src/.internal-tests/routers.test.ts b/packages/api/src/.internal-tests/routers.test.ts index af18dc21..2b7d422d 100644 --- a/packages/api/src/.internal-tests/routers.test.ts +++ b/packages/api/src/.internal-tests/routers.test.ts @@ -1024,6 +1024,9 @@ describe("Router Integration and Access Control Verification Suite", () => { it("should return public participant list for a hackathon", async () => { const ctx = createMockCtx(); + mockFindFirst.mockImplementation((table: string) => + table === "hackathons" ? { id: hackathonId, status: "open" } : undefined, + ); mockFindMany.mockReturnValue([ { hackathonId, @@ -1040,6 +1043,22 @@ describe("Router Integration and Access Control Verification Suite", () => { // event-pass QR payload and is deliberately not returned. expect(res[0].user.id).toBe("u1"); }); + + // A draft edition is one nobody outside the team is meant to know exists; + // its roster answers like the schedule and gallery do. + it("should hide a draft hackathon's participant list", async () => { + mockFindFirst.mockImplementation((table: string) => + table === "hackathons" ? { id: hackathonId, status: "draft" } : undefined, + ); + mockFindMany.mockReturnValue([ + { hackathonId, teamId: null, user: { id: "u1", name: "Ada", image: null }, team: null }, + ]); + + const caller = appRouter.createCaller(createMockCtx()); + await expect(caller.hackathon.participants({ hackathonId })).rejects.toThrow( + /not found/i, + ); + }); }); describe("9. Hackathon Creation, Updates and Admin Operations (does it create stuff)", () => { diff --git a/packages/api/src/middleware/cache.ts b/packages/api/src/middleware/cache.ts index 1b263dec..295926a7 100644 --- a/packages/api/src/middleware/cache.ts +++ b/packages/api/src/middleware/cache.ts @@ -249,6 +249,9 @@ export const clearMembershipCaches = (userId: string) => { // `member:me:`. Evict what is written. cache.deletePattern(`member:me:${userId}*`); cache.deletePattern(`member:status:${userId}*`); + cache.deletePattern(`member:history:${userId}*`); + // The staff directory lists every member; `member:*` does not match it. + cache.deletePattern("members:list:*"); invalidatePortalContext(userId); }; diff --git a/packages/api/src/routers/hackathon/registration.ts b/packages/api/src/routers/hackathon/registration.ts index 22129278..bdba6835 100644 --- a/packages/api/src/routers/hackathon/registration.ts +++ b/packages/api/src/routers/hackathon/registration.ts @@ -9,6 +9,7 @@ import { } from "@query/db"; import { eq, and, sql } from "drizzle-orm"; import type { DrizzleDB } from "@query/db"; +import { assertHackathonVisible } from "./visibility"; // Postgres unique_violation on unique_participant_per_hackathon — a second // submission of the same form. Drizzle wraps every driver error in a @@ -293,6 +294,10 @@ export const hackathonRegistrationRouter = createTRPCRouter({ participants: publicProcedure .input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") })) .query(async ({ ctx, input }) => { + // Public, so it answers to the same visibility rule as the schedule and + // gallery: a draft edition's roster is staff-only. + await assertHackathonVisible(ctx, input.hackathonId); + const cacheKey = `hackathon:${input.hackathonId}:participants`; const cached = ctx.cache.get(cacheKey); if (cached) return cached; diff --git a/packages/api/src/routers/judge/admin.ts b/packages/api/src/routers/judge/admin.ts index 9e13bef2..200d042f 100644 --- a/packages/api/src/routers/judge/admin.ts +++ b/packages/api/src/routers/judge/admin.ts @@ -382,6 +382,11 @@ export const judgeAdminRouter = createTRPCRouter({ }) .returning(); + // Same as approval: the role gate and the sidebar both cache, so the new + // judge would otherwise wait out a 5-minute TTL for the Judge tab. + ctx.cache.deletePattern(`${CacheKeys.judge(input.userId)}*`); + invalidatePortalContext(input.userId); + return result[0]; }), @@ -896,9 +901,16 @@ export const judgeAdminRouter = createTRPCRouter({ }); } - await (ctx.db as DrizzleDB) + const [removed] = await (ctx.db as DrizzleDB) .delete(judges) - .where(eq(judges.id, input.judgeId)); + .where(eq(judges.id, input.judgeId)) + .returning({ userId: judges.userId }); + + // Or the removed judge keeps a Judge tab every procedure behind it refuses. + if (removed) { + ctx.cache.deletePattern(`${CacheKeys.judge(removed.userId)}*`); + invalidatePortalContext(removed.userId); + } return { success: true }; }), diff --git a/packages/api/src/routers/team.ts b/packages/api/src/routers/team.ts index eeec93db..b7f0c76e 100644 --- a/packages/api/src/routers/team.ts +++ b/packages/api/src/routers/team.ts @@ -10,6 +10,7 @@ import { import { eq, and, or, isNull, inArray, lt, sql } from "drizzle-orm"; import { VOLATILE_TTL } from "../middleware/cache"; import type { DrizzleDB } from "@query/db"; +import { assertHackathonVisible } from "./hackathon/visibility"; type Tx = Parameters[0]>[0]; @@ -980,6 +981,8 @@ export const teamRouter = createTRPCRouter({ list: protectedProcedure .input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") })) .query(async ({ ctx, input }) => { + await assertHackathonVisible(ctx, input.hackathonId); + const cacheKey = `hackathon:${input.hackathonId}:teams`; const fetchTeams = () => diff --git a/sites/mainweb/app/(portal)/api/resume-book/route.ts b/sites/mainweb/app/(portal)/api/resume-book/route.ts index cf22ce5e..c43ca7da 100644 --- a/sites/mainweb/app/(portal)/api/resume-book/route.ts +++ b/sites/mainweb/app/(portal)/api/resume-book/route.ts @@ -26,7 +26,11 @@ export const dynamic = "force-dynamic"; const PREFETCH = 8; const csvCell = (value: unknown) => { - const text = value == null ? "" : String(value); + let text = value == null ? "" : String(value); + // Names, schools and majors are member-typed. A leading = + - @ (or tab/CR) + // makes Excel and Sheets evaluate the cell as a formula when staff open the + // index; a leading apostrophe keeps it text. + if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`; return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; };