)`, keyed by table.
const mockCount = vi.fn();
+/** maxParticipants as the capacity check reads it; undefined = uncapped. */
+const mockMaxParticipants = vi.fn<() => number | undefined>(() => undefined);
// Acceptance emails are sent through a dynamic import; intercept it so we can
// see exactly who a mass-approval actually mailed.
const mockSendAcceptanceEmail = vi.fn();
@@ -101,7 +103,17 @@ vi.mock("@query/db", () => {
},
}),
select: vi.fn().mockImplementation(() => ({
- from: (tbl: any) => thenable([{ count: mockCount(tbl?._t) ?? 0 }]),
+ from: (tbl: any) => {
+ const count = mockCount(tbl?._t) ?? 0;
+ return thenable([
+ {
+ count,
+ n: count,
+ max:
+ tbl?._t === "hackathons" ? mockMaxParticipants() : undefined,
+ },
+ ]);
+ },
})),
},
admins: {
@@ -266,6 +278,7 @@ describe("Hackathon admin management edge cases", () => {
vi.clearAllMocks();
cache.clear();
mockCount.mockReturnValue(0);
+ mockMaxParticipants.mockImplementation(() => undefined);
mockFindMany.mockReturnValue([]);
mockInsert.mockReturnValue([{}]);
mockUpdate.mockReturnValue([{}]);
@@ -345,6 +358,43 @@ describe("Hackathon admin management edge cases", () => {
).rejects.toThrow(/Admin access required/);
});
+ // maxParticipants caps acceptances. Two seats, both taken: accepting one
+ // more is refused whole rather than going over.
+ it("refuses to accept past capacity", async () => {
+ const caller = adminCaller({}, "admin");
+ mockMaxParticipants.mockImplementation(() => 2);
+ const counts = [2, 1]; // seated, then newly accepted by this call
+ mockCount.mockImplementation((table: string) =>
+ table === "hackathonParticipants" ? counts.shift() : 0,
+ );
+
+ await expect(
+ caller.hackathon.batchUpdateParticipantStatus({
+ hackathonId: HACK_A,
+ participantIds: [PART_A1],
+ status: "approved",
+ }),
+ ).rejects.toThrow(/Only 0 seats left of 2/);
+ });
+
+ it("accepts while seats remain", async () => {
+ const caller = adminCaller({}, "admin");
+ mockMaxParticipants.mockImplementation(() => 2);
+ const counts = [1, 1];
+ mockCount.mockImplementation((table: string) =>
+ table === "hackathonParticipants" ? counts.shift() : 0,
+ );
+ mockUpdate.mockReturnValue([{ id: PART_A1, userId: "u1" }]);
+
+ await expect(
+ caller.hackathon.batchUpdateParticipantStatus({
+ hackathonId: HACK_A,
+ participantIds: [PART_A1],
+ status: "approved",
+ }),
+ ).resolves.toBeDefined();
+ });
+
it("lets a volunteer work a check-in desk", async () => {
const caller = volunteerCaller({
hackathonEvents: { id: EVENT_A, hackathonId: HACK_A },
diff --git a/packages/api/src/.internal-tests/hackathon-flow.test.ts b/packages/api/src/.internal-tests/hackathon-flow.test.ts
index d5b71732..8640c317 100644
--- a/packages/api/src/.internal-tests/hackathon-flow.test.ts
+++ b/packages/api/src/.internal-tests/hackathon-flow.test.ts
@@ -6,6 +6,8 @@ import { db } from "@query/db";
// Fully mock the DB at the file level. vi.mock factories are hoisted and
// file-scoped, so this mirrors the shape used by routers.test.ts.
const mockFindFirst = vi.fn();
+/** Rows a bare `await select().from().where()` resolves to. */
+const mockWhereRows = vi.fn<() => unknown[]>(() => [{ n: 1 }]);
const mockFindMany = vi.fn();
const mockInsert = vi.fn();
const mockUpdate = vi.fn();
@@ -74,6 +76,9 @@ vi.mock("@query/db", () => {
select: vi.fn().mockImplementation(() => ({
from: vi.fn().mockImplementation(() => ({
where: vi.fn().mockImplementation(() => ({
+ // Awaited directly: a count such as the judging queue check.
+ then: (ok: any, err: any) =>
+ Promise.resolve(mockWhereRows()).then(ok, err),
orderBy: vi.fn().mockResolvedValue([{ count: 0 }]),
groupBy: vi.fn().mockResolvedValue([]),
limit: vi.fn().mockResolvedValue([]),
@@ -305,7 +310,9 @@ describe("Hackathon end-to-end flow", () => {
);
});
- it("rejects registration when the hackathon is at capacity", async () => {
+ // Capacity caps acceptances, not applications: a full house still takes
+ // applications, and the seat is enforced when an organiser accepts.
+ it("takes an application when every seat is already taken", async () => {
mockFindFirst.mockImplementation((table) =>
table === "hackathons"
? openHackathon({ maxParticipants: 500, currentParticipants: 500 })
@@ -313,9 +320,10 @@ describe("Hackathon end-to-end flow", () => {
);
const caller = appRouter.createCaller(createMockCtx("user_a"));
- await expect(caller.hackathon.register(registrationInput())).rejects.toThrow(
- /full/,
- );
+ const outcome = await caller.hackathon
+ .register(registrationInput())
+ .catch((error: unknown) => error);
+ expect(String((outcome as Error)?.message ?? "")).not.toMatch(/full/);
});
});
@@ -541,6 +549,7 @@ describe("Hackathon end-to-end flow", () => {
it("only lets an admin toggle judging on", async () => {
const caller = adminCaller();
+ mockWhereRows.mockReturnValueOnce([{ n: 12 }]);
mockUpdate.mockReturnValue([{ judgingActive: true }]);
const res = await caller.judge.toggleJudging({
@@ -550,6 +559,17 @@ describe("Hackathon end-to-end flow", () => {
expect(res).toMatchObject({ success: true, judgingActive: true });
});
+ // With nothing queued every judge opened straight onto "All done, 0 of 0".
+ it("refuses to open judging before any judge has a table", async () => {
+ const caller = adminCaller();
+ mockWhereRows.mockReturnValueOnce([{ n: 0 }]);
+
+ await expect(
+ caller.judge.toggleJudging({ hackathonId: HACK_A, active: true }),
+ ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" });
+ expect(mockUpdate).not.toHaveBeenCalled();
+ });
+
it("refuses to let a non-admin toggle judging", async () => {
mockFindFirst.mockImplementation(() => undefined);
const caller = appRouter.createCaller(createMockCtx("random_user"));
@@ -604,12 +624,14 @@ describe("Hackathon end-to-end flow", () => {
maxMembers: 4 as const,
};
- it("refuses team creation before the window opens at +12h", async () => {
- const caller = teamCaller(2);
+ // Accepted hackers can find teammates before kickoff.
+ it("allows team creation before hacking starts", async () => {
+ const caller = teamCaller(-48);
+ mockInsert.mockReturnValue([{ id: "team_1", name: "meow" }]);
- await expect(caller.team.createTeam(newTeam)).rejects.toThrow(
- /not open yet/,
- );
+ await expect(caller.team.createTeam(newTeam)).resolves.toMatchObject({
+ name: "meow",
+ });
});
it("allows team creation inside the window", async () => {
@@ -649,18 +671,21 @@ describe("Hackathon end-to-end flow", () => {
expect(res.canLeave).toBe(false);
});
+ // Teams form from acceptance, not from +12h: two hours in, an accepted
+ // hacker can already create, join and leave.
it("reports the window so the UI can disable instead of failing", async () => {
const caller = teamCaller(2);
const res = await caller.team.window({ hackathonId: HACK_A });
expect(res).toMatchObject({
- isOpen: false,
- canCreate: false,
- canJoin: false,
- canLeave: false,
+ isOpen: true,
+ canCreate: true,
+ canJoin: true,
+ canLeave: true,
+ opensAt: null,
});
- expect(res.leaveLocksAt.getTime() - res.opensAt.getTime()).toBe(
- 12 * HOUR,
+ expect(res.closesAt.getTime() - res.leaveLocksAt.getTime()).toBe(
+ 10 * HOUR,
);
});
});
diff --git a/packages/api/src/.internal-tests/judge-edge.test.ts b/packages/api/src/.internal-tests/judge-edge.test.ts
index 3e80d96c..1f8ce744 100644
--- a/packages/api/src/.internal-tests/judge-edge.test.ts
+++ b/packages/api/src/.internal-tests/judge-edge.test.ts
@@ -1264,22 +1264,23 @@ describe("Judge edge cases", () => {
});
/**
- * 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.
+ * Approving a judge is part of running the event, so any admin may; it
+ * was super-admin only while every admin saw the button. Deleting the
+ * judge, votes and all, stays super-admin.
*/
- it("refuses a plain admin, and writes nothing", async () => {
+ it("lets a plain admin approve a judge but not remove one", 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" });
+ const approval = await adminCaller()
+ .judge.setActive({ judgeId: JUDGE_ID, isActive: true })
+ .catch((error: unknown) => error);
+ expect((approval as { code?: string })?.code).not.toBe("FORBIDDEN");
+
await expect(
adminCaller().judge.remove({ judgeId: JUDGE_ID }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
-
- expect(mockUpdate).not.toHaveBeenCalled();
expect(mockDelete).not.toHaveBeenCalled();
});
diff --git a/packages/api/src/.internal-tests/participant-edge.test.ts b/packages/api/src/.internal-tests/participant-edge.test.ts
index 87f1b39b..a7299164 100644
--- a/packages/api/src/.internal-tests/participant-edge.test.ts
+++ b/packages/api/src/.internal-tests/participant-edge.test.ts
@@ -504,15 +504,11 @@ describe("Participant edge cases", () => {
},
);
- it("lets a team form at exactly +12h but not one millisecond earlier", async () => {
- const early = atOffset(12 * HOUR - 1);
+ // No opening time: the window is open from acceptance up to +34h.
+ it("lets a team form a day before hacking starts", async () => {
+ const early = atOffset(-24 * HOUR);
await expect(
early.team.createTeam({ hackathonId: HACK_A, name: "meow", maxMembers: 4 }),
- ).rejects.toThrow(/not open yet/);
-
- const onTime = atOffset(12 * HOUR);
- await expect(
- onTime.team.createTeam({ hackathonId: HACK_A, name: "meow", maxMembers: 4 }),
).resolves.toMatchObject({ id: TEAM_A });
});
@@ -865,6 +861,62 @@ describe("Participant edge cases", () => {
});
// =====================================================================
+ // There was no way to take a registration back, so an accepted no-show
+ // kept a seat for good. Allowed only before you are part of the event.
+ describe("Withdrawing a registration", () => {
+ const wire = (participant: Record | undefined) =>
+ mockFindFirst.mockImplementation((table: string) =>
+ table === "hackathonParticipants" ? participant : undefined,
+ );
+
+ it("removes a pending applicant who has nothing attached", async () => {
+ wire({ id: PARTICIPANT_A, teamId: null, registrationStatus: "pending" });
+ mockDelete.mockReturnValueOnce([{ id: PARTICIPANT_A }]);
+
+ await expect(
+ callerFor("user_a").hackathon.withdrawRegistration({
+ hackathonId: HACK_A,
+ }),
+ ).resolves.toEqual({ success: true });
+ expect(deletedTables()).toContain(hackathonParticipants);
+ });
+
+ // A badge scan or team join between the read and the delete: the
+ // conditions carried into the DELETE match nothing, and nothing is lost.
+ it("refuses when the registration changed after it was read", async () => {
+ wire({ id: PARTICIPANT_A, teamId: null, registrationStatus: "approved" });
+ mockDelete.mockReturnValueOnce([]);
+
+ await expect(
+ callerFor("user_a").hackathon.withdrawRegistration({
+ hackathonId: HACK_A,
+ }),
+ ).rejects.toMatchObject({ code: "CONFLICT" });
+ });
+
+ it.each([
+ [
+ "a team member",
+ { id: PARTICIPANT_A, teamId: TEAM_A, registrationStatus: "approved" },
+ /Leave your team first/,
+ ],
+ [
+ "somebody already checked in",
+ { id: PARTICIPANT_A, teamId: null, registrationStatus: "checked_in" },
+ /already checked in/,
+ ],
+ ])("refuses %s", async (_label, participant, message) => {
+ wire(participant);
+
+ await expect(
+ callerFor("user_a").hackathon.withdrawRegistration({
+ hackathonId: HACK_A,
+ }),
+ ).rejects.toThrow(message);
+ expect(mockDelete).not.toHaveBeenCalled();
+ });
+ });
+
describe("6. Registration under contention", () => {
// BUG: the duplicate guard at registration.ts:95-108 is an unlocked
// findFirst; the hackathon_participant_hackathon_user_idx violation that follows is
@@ -887,9 +939,10 @@ describe("Participant edge cases", () => {
});
});
- // BUG: registration.ts:110-118 reads currentParticipants with a plain
- // findFirst and increments at :173 — two racers both see 499/500.
- it("admits only one of two racing registrations into the last seat", async () => {
+ // Applying no longer claims a seat — acceptance does (admin.ts
+ // assertSeats) — so two applicants racing for the last seat both get in,
+ // and the seat count is untouched.
+ it("lets racing applications through without taking a seat", async () => {
const hackathonRow = runningHackathon(-24, {
maxParticipants: 500,
currentParticipants: 499,
@@ -898,30 +951,16 @@ describe("Participant edge cases", () => {
table === "hackathons" ? { ...hackathonRow } : undefined,
);
mockInsert.mockReturnValue([{ id: PARTICIPANT_A }]);
- mockUpdate.mockImplementation((_op, updateArgs) => {
- // Stands in for sql`currentParticipants + 1`, including the rollback:
- // the loser of the race claims a seat and then hands it straight back
- // when the re-read shows it went over.
- if (updateArgs[0] === hackathons) {
- hackathonRow.currentParticipants += 1;
- __onRollback(() => {
- hackathonRow.currentParticipants -= 1;
- });
- }
- return [];
- });
const results = await Promise.allSettled([
callerFor("user_a").hackathon.register(registrationInput()),
callerFor("user_b").hackathon.register(registrationInput()),
]);
- const rejected = results.filter((r) => r.status === "rejected");
- expect(rejected).toHaveLength(1);
- expect(String((rejected[0] as PromiseRejectedResult)?.reason)).toMatch(
- /full/,
- );
- expect(hackathonRow.currentParticipants).toBe(500);
+ expect(results.every((r) => r.status === "fulfilled")).toBe(true);
+ expect(
+ mockUpdate.mock.calls.some((call) => call[1]?.[0] === hackathons),
+ ).toBe(false);
});
// BUG: registration.ts:178 calls deletePattern("hackathon*") inside the
@@ -955,6 +994,7 @@ describe("Participant edge cases", () => {
};
return undefined;
});
+ mockUpdate.mockReturnValue([{ id: PARTICIPANT_A }]);
await callerFor("admin_user_id").hackathon.updateParticipantStatus({
hackathonId: HACK_A,
@@ -965,6 +1005,34 @@ describe("Participant edge cases", () => {
expect(updatedTables()).toContain(hackathons);
});
+ it("refuses a check-in when a concurrent change unseated the participant", async () => {
+ mockFindFirst.mockImplementation((table) => {
+ if (table === "admins")
+ return { userId: "admin_user_id", isActive: true, role: "admin" };
+ if (table === "hackathonParticipants")
+ return {
+ id: PARTICIPANT_A,
+ hackathonId: HACK_A,
+ registrationStatus: "approved",
+ };
+ return undefined;
+ });
+ // The seated-only WHERE matched nothing: someone waitlisted them between
+ // the read and the write.
+ mockUpdate.mockReturnValue([]);
+
+ await expect(
+ callerFor("admin_user_id").hackathon.updateParticipantStatus({
+ hackathonId: HACK_A,
+ participantId: PARTICIPANT_A,
+ status: "checked_in",
+ }),
+ ).rejects.toMatchObject({
+ code: "CONFLICT",
+ message: expect.stringContaining("changed just now"),
+ });
+ });
+
// BUG: registration.ts:222-239 is a publicProcedure whose column allow-list
// includes registrationStatus, and the query has no limit.
it("hides who was rejected from the public roster", async () => {
diff --git a/packages/api/src/.internal-tests/qr-checkin.test.ts b/packages/api/src/.internal-tests/qr-checkin.test.ts
index 32a0bb17..2bbb5c03 100644
--- a/packages/api/src/.internal-tests/qr-checkin.test.ts
+++ b/packages/api/src/.internal-tests/qr-checkin.test.ts
@@ -100,6 +100,7 @@ vi.mock("@query/db", async () => {
hackathonParticipants: table("hackathonParticipants"),
hackathonTeams: table("hackathonTeams"),
hackathonProjects: table("hackathonProjects"),
+ judgingProjects: table("judgingProjects"),
hackathonEvents: table("hackathonEvents"),
hackathonEventAttendees: table("hackathonEventAttendees"),
members: table("members"),
@@ -171,6 +172,12 @@ vi.mock("@query/db", async () => {
status: "status",
submittedAt: "submitted_at",
},
+ judgingProjects: {
+ id: "id",
+ sourceProjectId: "source_project_id",
+ withdrawnAt: "withdrawn_at",
+ tableNumber: "table_number",
+ },
hackathonEvents: {
id: "id",
hackathonId: "hackathon_id",
diff --git a/packages/api/src/.internal-tests/routers.test.ts b/packages/api/src/.internal-tests/routers.test.ts
index c10cf73c..66416552 100644
--- a/packages/api/src/.internal-tests/routers.test.ts
+++ b/packages/api/src/.internal-tests/routers.test.ts
@@ -712,10 +712,33 @@ describe("Router Integration and Access Control Verification Suite", () => {
).rejects.toThrow();
});
+ // Any uuid used to pass; a missing one hit the foreign key as a 500.
+ it.each([
+ ["a missing hackathon", null],
+ ["a draft", { id: "h", isPublic: true, status: "draft" }],
+ ["a hidden edition", { id: "h", isPublic: false, status: "open" }],
+ ])("refuses a judge application to %s", async (_label, hackathon) => {
+ const ctx = createMockCtx("applicant_user_id");
+ mockFindFirst.mockImplementation((table) =>
+ table === "hackathons" ? hackathon : null,
+ );
+
+ await expect(
+ appRouter.createCaller(ctx).judge.register({
+ hackathonId: "00000000-0000-4000-8000-000000000001",
+ name: "Ada Lovelace",
+ email: "ada@example.com",
+ }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ });
+
it("should prevent registered participants from applying to be a judge", async () => {
const ctx = createMockCtx("participant_user_id");
const hackathonId = "00000000-0000-4000-8000-000000000001";
mockFindFirst.mockImplementation((table) => {
+ if (table === "hackathons") {
+ return { id: hackathonId, isPublic: true, status: "open" };
+ }
if (table === "hackathonParticipants") {
return {
id: "participant_1",
@@ -942,7 +965,7 @@ describe("Router Integration and Access Control Verification Suite", () => {
describe("8. Hackathon Participant Registration (does it add users to the hackathon)", () => {
const hackathonId = "00000000-0000-4000-8000-000000000010";
- it("should successfully register a user for an open hackathon and increment participant count", async () => {
+ it("registers a user for an open hackathon without taking a seat", async () => {
const ctx = createMockCtx("new_user_id");
mockFindFirst.mockImplementation((table) => {
@@ -994,7 +1017,8 @@ describe("Router Integration and Access Control Verification Suite", () => {
expect(res.id).toBe("participant_new");
expect(res.userId).toBe("new_user_id");
expect(mockInsert).toHaveBeenCalled();
- expect(mockUpdate).toHaveBeenCalled();
+ // Capacity counts accepted people; applying leaves the count alone.
+ expect(mockUpdate).not.toHaveBeenCalled();
});
it("should reject registration if user is already registered", async () => {
@@ -1029,7 +1053,7 @@ describe("Router Integration and Access Control Verification Suite", () => {
).rejects.toThrowError("You are already registered for this hackathon");
});
- it("should reject registration if hackathon capacity is full", async () => {
+ it("takes an application even when every seat is taken", async () => {
const ctx = createMockCtx("user_id");
mockFindFirst.mockImplementation((table) => {
@@ -1045,8 +1069,9 @@ describe("Router Integration and Access Control Verification Suite", () => {
});
const caller = appRouter.createCaller(ctx);
- await expect(
- caller.hackathon.register({
+ // Every seat taken still takes the application: capacity is enforced
+ // when an organiser accepts, so the full house can feed a waitlist.
+ const outcome = await caller.hackathon.register({
hackathonId,
firstName: "Jane",
lastName: "Smith",
@@ -1059,8 +1084,9 @@ describe("Router Integration and Access Control Verification Suite", () => {
country: "United States",
whyAttend: "I want to build with data.",
agreeToCodeOfConduct: true,
- }),
- ).rejects.toThrowError("This hackathon is full");
+ })
+ .catch((error: unknown) => error);
+ expect(String((outcome as Error)?.message ?? "")).not.toMatch(/full/);
});
it("should reject registration if hackathon status is not open", async () => {
diff --git a/packages/api/src/routers/hackathon/admin.ts b/packages/api/src/routers/hackathon/admin.ts
index 65c19076..0955a4c8 100644
--- a/packages/api/src/routers/hackathon/admin.ts
+++ b/packages/api/src/routers/hackathon/admin.ts
@@ -8,6 +8,7 @@ import { createTRPCRouter } from "../../trpc";
import { isAdmin, isScanner } from "../../middleware/procedures";
import { isUniqueViolation } from "../../middleware/db-errors";
import { recordAdminAction } from "../../middleware/audit";
+import { CacheKeys } from "../../middleware/cache";
import { MASS_EMAIL_BATCH } from "../../services/email-limits";
import {
hackathons,
@@ -16,16 +17,22 @@ import {
hackathonEventAttendees,
users,
} from "@query/db";
-import { eq, and, inArray, ne, sql } from "drizzle-orm";
+import { eq, and, inArray, ne, notInArray, sql } from "drizzle-orm";
import type { DrizzleDB } from "@query/db";
-// Re-derives currentParticipants from the rows that actually hold a seat.
-// Rejected and waitlisted applicants do not, and registration.ts only ever
-// increments, so a rejected applicant would consume capacity forever. The
+// A seat belongs to somebody accepted. maxParticipants caps acceptances, not
+// applications: applying is free, so an organiser can take more applications
+// than seats and fill them in waves.
+const SEATED_STATUSES = ["approved", "checked_in"] as const;
+
+// Re-derives currentParticipants from the rows that actually hold a seat. The
// hackathon row is locked first: `SET x = (subquery)` plans the subquery once
// per statement, so a register() committing mid-wait would be overwritten and
// the seat total would drift low enough to admit people past maxParticipants.
-const syncCurrentParticipants = (db: DrizzleDB, hackathonId: string) =>
+export const syncCurrentParticipants = (
+ db: DrizzleDB,
+ hackathonId: string,
+) =>
db.transaction(async (tx) => {
await tx
.select({ id: hackathons.id })
@@ -36,21 +43,73 @@ const syncCurrentParticipants = (db: DrizzleDB, hackathonId: string) =>
await tx
.update(hackathons)
.set({
- currentParticipants: sql`(select count(*)::int from ${hackathonParticipants} where ${hackathonParticipants.hackathonId} = ${hackathonId} and ${hackathonParticipants.registrationStatus} in ('pending', 'approved', 'checked_in'))`,
+ currentParticipants: sql`(select count(*)::int from ${hackathonParticipants} where ${hackathonParticipants.hackathonId} = ${hackathonId} and ${hackathonParticipants.registrationStatus} in ('approved', 'checked_in'))`,
})
.where(eq(hackathons.id, hackathonId));
});
+/**
+ * Refuses an acceptance that would pass maxParticipants. Call inside the
+ * transaction that writes the new statuses: it locks the hackathon row, so two
+ * organisers accepting at once are counted one after the other. People already
+ * seated are not counted twice.
+ */
+async function assertSeats(
+ tx: DrizzleDB,
+ hackathonId: string,
+ participantIds: string[],
+) {
+ const [hackathon] = await tx
+ .select({ max: hackathons.maxParticipants })
+ .from(hackathons)
+ .where(eq(hackathons.id, hackathonId))
+ .for("update");
+ if (!hackathon?.max || participantIds.length === 0) return;
+
+ const [seated] = await tx
+ .select({ n: sql`count(*)::int` })
+ .from(hackathonParticipants)
+ .where(
+ and(
+ eq(hackathonParticipants.hackathonId, hackathonId),
+ inArray(hackathonParticipants.registrationStatus, [...SEATED_STATUSES]),
+ ),
+ );
+ const [adding] = await tx
+ .select({ n: sql`count(*)::int` })
+ .from(hackathonParticipants)
+ .where(
+ and(
+ eq(hackathonParticipants.hackathonId, hackathonId),
+ inArray(hackathonParticipants.id, participantIds),
+ notInArray(hackathonParticipants.registrationStatus, [
+ ...SEATED_STATUSES,
+ ]),
+ ),
+ );
+
+ const left = Math.max(hackathon.max - (seated?.n ?? 0), 0);
+ if ((adding?.n ?? 0) > left) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: `Only ${left} seat${left === 1 ? "" : "s"} left of ${hackathon.max}; this would accept ${adding?.n ?? 0}. Raise the capacity or accept fewer.`,
+ });
+ }
+}
+
// Evicts exactly the keys a participant status change moves. The old
// `deletePattern("hackathon*")` matched both namespaces, so one badge scan
// wiped every attendee's cached registrations and the venue-wide events list.
// Each affected user's own registration list goes too, or an acceptance lands
-// in their inbox while their dashboard still says pending.
-const evictParticipantCaches = (
+// in their inbox while their dashboard still says pending. The hackathon row
+// goes as well: every caller has just synced currentParticipants, and getById
+// serves the public "spots taken" line from that cached row.
+export const evictParticipantCaches = (
cache: { delete: (key: string) => boolean },
hackathonId: string,
userIds: string[],
) => {
+ cache.delete(CacheKeys.hackathon(hackathonId));
cache.delete(`hackathon:${hackathonId}:participants`);
cache.delete(`hackathon:${hackathonId}:analytics`);
for (const userId of new Set(userIds)) {
@@ -248,17 +307,46 @@ export const hackathonAdminRouter = createTRPCRouter({
});
}
- await (ctx.db as DrizzleDB)
- .update(hackathonParticipants)
- .set({
- registrationStatus: input.status,
- // Stamp the arrival the first time only: re-checking someone in must not move
- // the timestamp the attendees table shows.
- ...(input.status === "checked_in" && !participant.checkedInAt
- ? { checkedInAt: new Date() }
- : {}),
- })
- .where(eq(hackathonParticipants.id, input.participantId));
+ await (ctx.db as DrizzleDB).transaction(async (tx) => {
+ // checked_in seats somebody too: the approved-only check above was
+ // read before this transaction, and a concurrent "waitlisted" would
+ // otherwise be checked in with no seat.
+ if (input.status === "approved" || input.status === "checked_in") {
+ await assertSeats(tx as unknown as DrizzleDB, input.hackathonId, [
+ input.participantId,
+ ]);
+ }
+ const updated = await tx
+ .update(hackathonParticipants)
+ .set({
+ registrationStatus: input.status,
+ // Stamp the arrival the first time only: re-checking someone in must not
+ // move the timestamp the attendees table shows.
+ ...(input.status === "checked_in" && !participant.checkedInAt
+ ? { checkedInAt: new Date() }
+ : {}),
+ })
+ .where(
+ and(
+ eq(hackathonParticipants.id, input.participantId),
+ input.status === "checked_in"
+ ? inArray(hackathonParticipants.registrationStatus, [
+ ...SEATED_STATUSES,
+ ])
+ : undefined,
+ ),
+ )
+ .returning({ id: hackathonParticipants.id });
+ // A concurrent waitlist or reject leaves the seated-only WHERE matching
+ // nothing; reporting success would wave them through the desk.
+ if (updated.length === 0) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message:
+ "This applicant's status changed just now. Refresh and check again before letting them in.",
+ });
+ }
+ });
await syncCurrentParticipants(ctx.db as DrizzleDB, input.hackathonId);
@@ -339,7 +427,7 @@ export const hackathonAdminRouter = createTRPCRouter({
});
}
- const { wave, picked } = await db.transaction(async (tx) => {
+ const { wave, picked, full } = 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.
@@ -360,6 +448,19 @@ export const hackathonAdminRouter = createTRPCRouter({
const wave = (highest?.max ?? 0) + 1;
+ // Never past capacity: a wave fills what is left and no more.
+ const [seats] = await tx
+ .select({
+ max: hackathons.maxParticipants,
+ seated: sql`(select count(*)::int from ${hackathonParticipants} where ${hackathonParticipants.hackathonId} = ${input.hackathonId} and ${hackathonParticipants.registrationStatus} in ('approved', 'checked_in'))`,
+ })
+ .from(hackathons)
+ .where(eq(hackathons.id, input.hackathonId));
+ const size = seats?.max
+ ? Math.min(input.size, Math.max(seats.max - seats.seated, 0))
+ : input.size;
+ if (size === 0) return { wave, picked: [], full: true };
+
const picked = await tx
.select({
id: hackathonParticipants.id,
@@ -373,10 +474,10 @@ export const hackathonAdminRouter = createTRPCRouter({
),
)
.orderBy(hackathonParticipants.registeredAt)
- .limit(input.size)
+ .limit(size)
.for("update", { skipLocked: true });
- if (picked.length === 0) return { wave, picked };
+ if (picked.length === 0) return { wave, picked, full: false };
await tx
.update(hackathonParticipants)
@@ -392,7 +493,7 @@ export const hackathonAdminRouter = createTRPCRouter({
),
);
- return { wave, picked };
+ return { wave, picked, full: false };
});
if (picked.length === 0) {
@@ -400,7 +501,9 @@ export const hackathonAdminRouter = createTRPCRouter({
wave,
accepted: 0,
participantIds: [] as string[],
- message: "No pending applications left to accept.",
+ message: full
+ ? "Every seat is taken. Raise the capacity to accept more."
+ : "No pending applications left to accept.",
};
}
@@ -493,22 +596,30 @@ export const hackathonAdminRouter = createTRPCRouter({
// One statement rather than one per recipient: this runs against the full
// accepted list, and a 500-round-trip transaction holds a pool connection for
- // its whole duration.
- await db
- .update(hackathonParticipants)
- .set({ registrationStatus: "approved", updatedAt: new Date() })
- .where(
- and(
- inArray(
- hackathonParticipants.id,
- participants.map((participant) => participant.id),
- ),
- eq(hackathonParticipants.hackathonId, hackathonId),
- // Repeated in SQL for a check-in that lands after the read above:
- // submitting a project requires checked_in.
- ne(hackathonParticipants.registrationStatus, "checked_in"),
- ),
+ // its whole duration. Checked against capacity first, in the same
+ // transaction, so the batch is accepted whole or not at all.
+ await db.transaction(async (tx) => {
+ await assertSeats(
+ tx as unknown as DrizzleDB,
+ hackathonId,
+ toAccept.map((participant) => participant.id),
);
+ await tx
+ .update(hackathonParticipants)
+ .set({ registrationStatus: "approved", updatedAt: new Date() })
+ .where(
+ and(
+ inArray(
+ hackathonParticipants.id,
+ participants.map((participant) => participant.id),
+ ),
+ eq(hackathonParticipants.hackathonId, hackathonId),
+ // Repeated in SQL for a check-in that lands after the read above:
+ // submitting a project requires checked_in.
+ ne(hackathonParticipants.registrationStatus, "checked_in"),
+ ),
+ );
+ });
// Approving a rejected or waitlisted applicant hands a seat back out.
await syncCurrentParticipants(db, hackathonId);
@@ -623,41 +734,50 @@ export const hackathonAdminRouter = createTRPCRouter({
// One statement, not one per id: 2000 sequential round trips would hold a
// pool connection for the whole batch. The (id, hackathonId) scoping survives
// in the AND, and the caller is told how many rows really changed.
- const rows = await (ctx.db as DrizzleDB)
- .update(hackathonParticipants)
- .set({
- registrationStatus: status,
- updatedAt: new Date(),
- // coalesce so re-checking in someone who already arrived keeps their original
- // time. `at time zone 'utc'` because the column is timestamp-without-tz: a
- // bare now() goes through the session TimeZone and would disagree with the
- // `new Date()` updateParticipantStatus writes for the same event.
- ...(status === "checked_in"
- ? {
- checkedInAt: sql`coalesce(${hackathonParticipants.checkedInAt}, now() at time zone 'utc')`,
- }
- : {}),
- })
- .where(
- and(
- inArray(hackathonParticipants.id, participantIds),
- eq(hackathonParticipants.hackathonId, hackathonId),
- // Same rule as the single-participant path, in the WHERE so a 2000-row
- // selection with a few unreviewed applicants still admits everybody else.
- // It matters more here: "Select all N matching" means one wrong click could
- // promote every pending applicant to a state that lets them submit.
- status === "checked_in"
- ? inArray(hackathonParticipants.registrationStatus, [
- "approved",
- "checked_in",
- ])
- : undefined,
- ),
- )
- .returning({
- id: hackathonParticipants.id,
- userId: hackathonParticipants.userId,
- });
+ const rows = await (ctx.db as DrizzleDB).transaction(async (tx) => {
+ if (status === "approved") {
+ await assertSeats(
+ tx as unknown as DrizzleDB,
+ hackathonId,
+ participantIds,
+ );
+ }
+ return tx
+ .update(hackathonParticipants)
+ .set({
+ registrationStatus: status,
+ updatedAt: new Date(),
+ // coalesce so re-checking in someone who already arrived keeps their original
+ // time. `at time zone 'utc'` because the column is timestamp-without-tz: a
+ // bare now() goes through the session TimeZone and would disagree with the
+ // `new Date()` updateParticipantStatus writes for the same event.
+ ...(status === "checked_in"
+ ? {
+ checkedInAt: sql`coalesce(${hackathonParticipants.checkedInAt}, now() at time zone 'utc')`,
+ }
+ : {}),
+ })
+ .where(
+ and(
+ inArray(hackathonParticipants.id, participantIds),
+ eq(hackathonParticipants.hackathonId, hackathonId),
+ // Same rule as the single-participant path, in the WHERE so a 2000-row
+ // selection with a few unreviewed applicants still admits everybody else.
+ // It matters more here: "Select all N matching" means one wrong click could
+ // promote every pending applicant to a state that lets them submit.
+ status === "checked_in"
+ ? inArray(hackathonParticipants.registrationStatus, [
+ "approved",
+ "checked_in",
+ ])
+ : undefined,
+ ),
+ )
+ .returning({
+ id: hackathonParticipants.id,
+ userId: hackathonParticipants.userId,
+ });
+ });
await syncCurrentParticipants(ctx.db as DrizzleDB, hackathonId);
diff --git a/packages/api/src/routers/hackathon/interest.ts b/packages/api/src/routers/hackathon/interest.ts
index 22007f4d..f37be48f 100644
--- a/packages/api/src/routers/hackathon/interest.ts
+++ b/packages/api/src/routers/hackathon/interest.ts
@@ -59,7 +59,12 @@ const CLAIM_TIMEOUT_MS = 15 * 60 * 1000;
// meant that the moment registration opened, the one page telling the world
// about the hackathon said "Nothing announced yet". Soonest first, so
// announcing the year after next does not displace the one being promoted.
-const PUBLIC_FUNNEL_STATUSES = ["announced", "open", "in_progress"] as const;
+const PUBLIC_FUNNEL_STATUSES = [
+ "announced",
+ "open",
+ "closed",
+ "in_progress",
+] as const;
/** What the landing page reads; also the shape held in the cache. */
type UpcomingEdition = {
diff --git a/packages/api/src/routers/hackathon/registration.ts b/packages/api/src/routers/hackathon/registration.ts
index 84b90b30..e4db769a 100644
--- a/packages/api/src/routers/hackathon/registration.ts
+++ b/packages/api/src/routers/hackathon/registration.ts
@@ -5,11 +5,13 @@ import { CacheKeys } from "../../middleware/cache";
import {
hackathons,
hackathonParticipants,
+ hackathonProjects,
members,
} from "@query/db";
-import { eq, and, sql } from "drizzle-orm";
+import { eq, and, isNull, ne } from "drizzle-orm";
import type { DrizzleDB } from "@query/db";
import { assertHackathonVisible } from "./visibility";
+import { evictParticipantCaches, syncCurrentParticipants } from "./admin";
// Postgres unique_violation on hackathon_participant_hackathon_user_idx — a second
// submission of the same form. Drizzle wraps every driver error in a
@@ -51,7 +53,13 @@ export const hackathonRegistrationRouter = createTRPCRouter({
// Academic info
school: z.string().min(1).max(300),
major: z.string().min(1).max(300),
- graduationYear: z.number().int().min(2020).max(2035),
+ // Relative to now: a fixed 2020-2035 accepted years already past and
+ // would start refusing real students in 2036.
+ graduationYear: z
+ .number()
+ .int()
+ .min(new Date().getFullYear() - 1)
+ .max(new Date().getFullYear() + 8),
levelOfStudy: z.enum([
"Freshman",
"Sophomore",
@@ -138,18 +146,9 @@ export const hackathonRegistrationRouter = createTRPCRouter({
});
}
- // Nothing is locked yet, so this only turns away a form submitted against an
- // event that was already visibly full; the seat is claimed and checked below.
- if (
- hackathon.maxParticipants &&
- hackathon.currentParticipants >= hackathon.maxParticipants
- ) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "This hackathon is full",
- });
- }
-
+ // No seat is claimed here: capacity counts accepted people, not
+ // applications, so applying is never refused for being full. The
+ // seat is taken when an organiser accepts (admin.ts assertSeats).
// A membership is annual and edition-independent, so it is keyed on the
// person alone; the edition clause used to be here and made a paying member
// read as a non-member the moment a new edition opened.
@@ -157,35 +156,6 @@ export const hackathonRegistrationRouter = createTRPCRouter({
where: eq(members.userId, ctx.userId as string),
});
- // Claiming the seat before inserting anything is what makes capacity hold
- // across processes: this statement takes the hackathon row's exclusive lock,
- // so a registration racing for the same last seat blocks here and re-runs
- // `+ 1` against the count we wrote rather than its own snapshot. Reading the
- // row back in the same transaction gives the seat this registration actually
- // holds, and going over the limit rolls the claim back. It also keeps
- // admin.ts's recount honest — that path locks the same row first.
- await tx
- .update(hackathons)
- .set({
- currentParticipants: sql`${hackathons.currentParticipants} + 1`,
- })
- .where(eq(hackathons.id, input.hackathonId));
-
- const claimed = await tx.query.hackathons.findFirst({
- where: eq(hackathons.id, input.hackathonId),
- columns: { currentParticipants: true, maxParticipants: true },
- });
-
- if (
- claimed?.maxParticipants &&
- claimed.currentParticipants > claimed.maxParticipants
- ) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "This hackathon is full",
- });
- }
-
const [participant] = await tx
.insert(hackathonParticipants)
.values({
@@ -260,6 +230,103 @@ export const hackathonRegistrationRouter = createTRPCRouter({
}),
+ // Takes back your own registration. There was no way out at all, so an
+ // accepted no-show kept a seat for good. Refused once you are part of the
+ // event — on a team, checked in, or with a project — since undoing any of
+ // those affects other people and is an organiser's call.
+ withdrawRegistration: protectedProcedure
+ .input(z.object({ hackathonId: z.string().uuid() }))
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const participant = await db.query.hackathonParticipants.findFirst({
+ where: and(
+ eq(hackathonParticipants.hackathonId, input.hackathonId),
+ eq(hackathonParticipants.userId, ctx.userId as string),
+ ),
+ columns: { id: true, teamId: true, registrationStatus: true },
+ });
+
+ if (!participant) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "You are not registered for this hackathon.",
+ });
+ }
+
+ // A finished event's registrations are its record of who took part.
+ const hackathon = await db.query.hackathons.findFirst({
+ where: eq(hackathons.id, input.hackathonId),
+ columns: { status: true },
+ });
+ if (
+ hackathon?.status === "completed" ||
+ hackathon?.status === "cancelled"
+ ) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "This hackathon is over, so registrations can't be withdrawn.",
+ });
+ }
+
+ if (participant.registrationStatus === "checked_in") {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message:
+ "You have already checked in. Ask an organiser if you need to leave the event.",
+ });
+ }
+ if (participant.teamId) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Leave your team first, then withdraw.",
+ });
+ }
+
+ const project = await db.query.hackathonProjects.findFirst({
+ where: and(
+ eq(hackathonProjects.hackathonId, input.hackathonId),
+ eq(hackathonProjects.submittedById, participant.id),
+ ),
+ columns: { id: true },
+ });
+ if (project) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Withdraw your project first, then your registration.",
+ });
+ }
+
+ // The checks above are a read; a badge scan or a team join can land
+ // before this. Repeating them here deletes nothing in that case.
+ const removed = await db
+ .delete(hackathonParticipants)
+ .where(
+ and(
+ eq(hackathonParticipants.id, participant.id),
+ ne(hackathonParticipants.registrationStatus, "checked_in"),
+ isNull(hackathonParticipants.teamId),
+ ),
+ )
+ .returning({ id: hackathonParticipants.id });
+ if (removed.length === 0) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message:
+ "Your registration changed just now. Refresh the page and try again.",
+ });
+ }
+
+ // An accepted withdrawal frees a seat for the next wave.
+ await syncCurrentParticipants(db, input.hackathonId);
+
+ evictParticipantCaches(ctx.cache, input.hackathonId, [
+ ctx.userId as string,
+ ]);
+
+ return { success: true };
+ }),
+
myRegistrations: protectedProcedure.query(async ({ ctx }) => {
const cacheKey = `hackathon:registrations:${ctx.userId}`;
const cached = ctx.cache.get(cacheKey);
diff --git a/packages/api/src/routers/judge/admin.ts b/packages/api/src/routers/judge/admin.ts
index 17f228d9..4edc8322 100644
--- a/packages/api/src/routers/judge/admin.ts
+++ b/packages/api/src/routers/judge/admin.ts
@@ -338,29 +338,42 @@ export const judgeAdminRouter = createTRPCRouter({
return allJudges;
}),
+ // Adds a judge directly — a sponsor or a walk-in — rather than waiting for
+ // them to apply. By email, because that is what an organiser has; the
+ // person needs an account, which signing in once creates.
create: isAdmin
.input(
- z.object({
- userId: z.string().min(1).max(255),
- hackathonId: z.string().uuid(),
- name: z.string().max(255).optional(),
- }),
+ z
+ .object({
+ userId: z.string().min(1).max(255).optional(),
+ email: z.string().trim().email().max(255).optional(),
+ hackathonId: z.string().uuid(),
+ name: z.string().max(255).optional(),
+ })
+ .refine((input) => !!input.userId || !!input.email, {
+ message: "Give the judge's email address.",
+ path: ["email"],
+ }),
)
.mutation(async ({ ctx, input }) => {
const user = await (ctx.db as DrizzleDB).query.users.findFirst({
- where: eq(users.id, input.userId),
+ where: input.userId
+ ? eq(users.id, input.userId)
+ : sql`lower(${users.email}) = lower(${input.email!})`,
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
- message: "User not found",
+ message: input.userId
+ ? "User not found"
+ : "No account uses that email. Ask them to sign in to the portal once, then add them.",
});
}
const existing = await (ctx.db as DrizzleDB).query.judges.findFirst({
where: and(
- eq(judges.userId, input.userId),
+ eq(judges.userId, user.id),
eq(judges.hackathonId, input.hackathonId),
),
});
@@ -375,17 +388,18 @@ export const judgeAdminRouter = createTRPCRouter({
const result = await (ctx.db as DrizzleDB)
.insert(judges)
.values({
- userId: input.userId,
+ userId: user.id,
hackathonId: input.hackathonId,
name: input.name || user.name,
+ email: user.email,
isActive: true, // Manually created judges are active by default
})
.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);
+ ctx.cache.deletePattern(`${CacheKeys.judge(user.id)}*`);
+ invalidatePortalContext(user.id);
return result[0];
}),
@@ -680,8 +694,9 @@ export const judgeAdminRouter = createTRPCRouter({
// Approve or suspend a judge. judge.register creates the row inactive and
// judge.create refuses once it exists, so without this a self-registered
- // judge can never be activated by any route.
- setActive: isSuperAdmin
+ // judge can never be activated by any route. Any admin: it was super-admin
+ // only while every admin saw the button, so most organisers got an error.
+ setActive: isAdmin
.input(
z.object({
judgeId: z.string().uuid(),
@@ -1566,6 +1581,26 @@ export const judgeAdminRouter = createTRPCRouter({
)
.mutation(async ({ ctx, input }) => {
return await (ctx.db as DrizzleDB).transaction(async (tx) => {
+ // Only an edition people can see and that is not over takes judges. Any
+ // uuid used to pass, and one that did not exist hit the foreign key as
+ // a 500. Announced counts: judges are recruited before registration.
+ const hackathon = await tx.query.hackathons.findFirst({
+ where: eq(hackathons.id, input.hackathonId),
+ columns: { isPublic: true, status: true },
+ });
+ if (
+ !hackathon ||
+ !hackathon.isPublic ||
+ !["announced", "open", "closed", "in_progress"].includes(
+ hackathon.status,
+ )
+ ) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "That hackathon is not taking judge applications.",
+ });
+ }
+
// Check if user is registered as a participant for this hackathon
const participant = await tx.query.hackathonParticipants.findFirst({
where: and(
diff --git a/packages/api/src/routers/judge/portal.ts b/packages/api/src/routers/judge/portal.ts
index eba687b2..c7229631 100644
--- a/packages/api/src/routers/judge/portal.ts
+++ b/packages/api/src/routers/judge/portal.ts
@@ -341,6 +341,25 @@ export const judgePortalRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
+ // Opening judging with nothing queued sent every judge straight to "All
+ // done, 0 of 0". Preparing (promote submissions, assign judges) is a
+ // separate step on the Judging page, so say so rather than open empty.
+ if (input.active) {
+ const [queued] = await (ctx.db as DrizzleDB)
+ .select({ n: sql`count(*)::int` })
+ .from(judgeQueue)
+ // Any row, scored or not: reopening judging so a judge can fix a
+ // score, after every slot is done, must still be allowed.
+ .where(eq(judgeQueue.hackathonId, input.hackathonId));
+ if ((queued?.n ?? 0) === 0) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message:
+ "No judge has a table to score yet. Prepare judging first: promote submissions and assign judges on the Judging page.",
+ });
+ }
+ }
+
const [updated] = await (ctx.db as DrizzleDB)
.update(hackathons)
.set({ judgingActive: input.active, updatedAt: new Date() })
diff --git a/packages/api/src/routers/team.ts b/packages/api/src/routers/team.ts
index 37491328..ee243934 100644
--- a/packages/api/src/routers/team.ts
+++ b/packages/api/src/routers/team.ts
@@ -19,23 +19,25 @@ type Tx = Parameters[0]>[0];
const HOUR = 60 * 60 * 1000;
/** All hackathon milestones, as hour offsets from the hacking start time. */
-const TEAM_WINDOW_OPEN_HOURS = 12;
+const SUBMISSION_OPEN_HOURS = 12;
const TEAM_WINDOW_CLOSE_HOURS = 34;
const SUBMISSION_HARD_DEADLINE_HOURS = 36;
/** Rosters freeze 12h before the hard submission deadline, i.e. at +24h. */
const LEAVE_LOCK_HOURS = SUBMISSION_HARD_DEADLINE_HOURS - 12;
+// Teams form any time before +34h, so accepted people can find teammates
+// before kickoff. Who may form one is checkAdmitted's job, not the clock's:
+// this window used to open only at +12h, a third of the way into the event.
export function computeTeamWindow(baseTime: Date, now: Date) {
const at = (hours: number) => new Date(baseTime.getTime() + hours * HOUR);
- const opensAt = at(TEAM_WINDOW_OPEN_HOURS);
const closesAt = at(TEAM_WINDOW_CLOSE_HOURS);
const leaveLocksAt = at(LEAVE_LOCK_HOURS);
- const isOpen = now >= opensAt && now <= closesAt;
+ const isOpen = now <= closesAt;
return {
- opensAt,
+ opensAt: null,
closesAt,
leaveLocksAt,
isOpen,
@@ -54,7 +56,7 @@ export function computeTeamWindow(baseTime: Date, now: Date) {
export function computeSubmissionWindow(baseTime: Date, now: Date) {
const at = (hours: number) => new Date(baseTime.getTime() + hours * HOUR);
- const opensAt = at(TEAM_WINDOW_OPEN_HOURS);
+ const opensAt = at(SUBMISSION_OPEN_HOURS);
/** After this, an existing submission is frozen — new ones still land. */
const editsCloseAt = at(TEAM_WINDOW_CLOSE_HOURS);
const closesAt = at(SUBMISSION_HARD_DEADLINE_HOURS);
@@ -115,13 +117,6 @@ async function checkTeamEditWindow(db: DrizzleDB, hackathonId: string) {
const window = await loadTeamWindow(db, hackathonId);
const now = new Date();
- if (now < window.opensAt) {
- throw new TRPCError({
- code: "FORBIDDEN",
- message:
- "Team creation and editing is not open yet. It starts 12 hours after the hacking begins.",
- });
- }
if (now > window.closesAt) {
throw new TRPCError({
code: "FORBIDDEN",
@@ -1142,8 +1137,19 @@ export const teamRouter = createTRPCRouter({
// belongs to the captain rather than the caller.
columns: { submittedById: false },
});
+ if (!project) return null;
+
+ // Judges are sent to a table number; the team is the one party that was
+ // never told it. Null until the project is promoted to judging.
+ const judging = await db.query.judgingProjects.findFirst({
+ where: and(
+ eq(judgingProjects.sourceProjectId, project.id),
+ isNull(judgingProjects.withdrawnAt),
+ ),
+ columns: { tableNumber: true },
+ });
- return project ?? null;
+ return { ...project, tableNumber: judging?.tableNumber ?? null };
}),
// Every project the caller owns, across hackathons. Reading off the team
diff --git a/sites/mainweb/app/(portal)/admin/analytics/page.tsx b/sites/mainweb/app/(portal)/admin/analytics/page.tsx
index 00aefe34..29a5be46 100644
--- a/sites/mainweb/app/(portal)/admin/analytics/page.tsx
+++ b/sites/mainweb/app/(portal)/admin/analytics/page.tsx
@@ -83,12 +83,12 @@ function StatCard({ icon: Icon, title, value, subtitle }: StatCardProps) {