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
3 changes: 3 additions & 0 deletions packages/api/src/.internal-tests/participant-edge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down
3 changes: 3 additions & 0 deletions packages/api/src/.internal-tests/qr-checkin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
19 changes: 19 additions & 0 deletions packages/api/src/.internal-tests/routers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)", () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/api/src/middleware/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,9 @@ export const clearMembershipCaches = (userId: string) => {
// `member:me:<userId>`. 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);
};

Expand Down
5 changes: 5 additions & 0 deletions packages/api/src/routers/hackathon/registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<typeof participants>(cacheKey);
if (cached) return cached;
Expand Down
16 changes: 14 additions & 2 deletions packages/api/src/routers/judge/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}),

Expand Down Expand Up @@ -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 };
}),

Expand Down
3 changes: 3 additions & 0 deletions packages/api/src/routers/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Parameters<DrizzleDB["transaction"]>[0]>[0];

Expand Down Expand Up @@ -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 = () =>
Expand Down
6 changes: 5 additions & 1 deletion sites/mainweb/app/(portal)/api/resume-book/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
Loading