From 6618ac57a8ede762af3a129316fb4f8b4a29f4e6 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Wed, 5 Aug 2026 12:38:27 -0400
Subject: [PATCH 01/10] more adding
---
packages/api/src/index.ts | 7 +
packages/api/src/middleware/cache.ts | 11 +
packages/api/src/middleware/procedures.ts | 67 +-
packages/api/src/root.ts | 2 +
packages/api/src/routers/initiative.ts | 749 ++++++++++++++++++
packages/api/src/services/portal-context.ts | 31 +-
packages/api/src/trpc.ts | 11 +
packages/api/src/types/portal-context.ts | 2 +
packages/db/src/schemas/index.ts | 1 +
packages/db/src/schemas/initiatives.ts | 181 +++++
.../app/(portal)/admin/initiatives/page.tsx | 150 ++++
.../mainweb/app/(portal)/initiatives/page.tsx | 322 ++++++++
sites/mainweb/app/(portal)/lead/[id]/page.tsx | 263 ++++++
sites/mainweb/app/(portal)/lead/page.tsx | 380 +++++++++
.../components/portal/PortalSidebar.tsx | 17 +
.../components/portal/StripePaymentModal.tsx | 39 +-
.../components/portal/initiatives/chips.tsx | 84 ++
17 files changed, 2306 insertions(+), 11 deletions(-)
create mode 100644 packages/api/src/routers/initiative.ts
create mode 100644 packages/db/src/schemas/initiatives.ts
create mode 100644 sites/mainweb/app/(portal)/admin/initiatives/page.tsx
create mode 100644 sites/mainweb/app/(portal)/initiatives/page.tsx
create mode 100644 sites/mainweb/app/(portal)/lead/[id]/page.tsx
create mode 100644 sites/mainweb/app/(portal)/lead/page.tsx
create mode 100644 sites/mainweb/components/portal/initiatives/chips.tsx
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index 0ae7dc62..a3243050 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -1,4 +1,11 @@
+import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
+import type { AppRouter as AppRouterType } from "./root";
+
export { appRouter, type AppRouter } from "./root";
+
+/** So a component types itself off the procedure instead of restating it. */
+export type RouterInputs = inferRouterInputs;
+export type RouterOutputs = inferRouterOutputs;
export { createContext, type Context } from "./context";
export { createTRPCRouter, publicProcedure, protectedProcedure } from "./trpc";
export { rateLimit, RATE_LIMITS, resolveClientIp } from "./middleware/security";
diff --git a/packages/api/src/middleware/cache.ts b/packages/api/src/middleware/cache.ts
index 1fa33776..7c46fe0c 100644
--- a/packages/api/src/middleware/cache.ts
+++ b/packages/api/src/middleware/cache.ts
@@ -243,6 +243,7 @@ export const CacheKeys = {
events: () => `events:list`,
judge: (userId: string) => `judge:${userId}`,
member: (userId: string) => `member:${userId}`,
+ projectLeader: (userId: string) => `project-leader:${userId}`,
portalContext: (userId: string) => `user:${userId}:portal`,
} as const;
@@ -250,6 +251,16 @@ export const invalidatePortalContext = (userId: string) => {
cache.delete(CacheKeys.portalContext(userId));
};
+/**
+ * The role gate caches per hackathon for 60s and the sidebar reads the portal
+ * context, so granting or revoking has to clear both or the new leader is shown
+ * a tab the procedures still refuse.
+ */
+export const clearProjectLeaderCaches = (userId: string) => {
+ cache.deletePattern(`${CacheKeys.projectLeader(userId)}*`);
+ invalidatePortalContext(userId);
+};
+
/**
* Everything that reports whether someone is a member. The portal context
* entry is the one that matters most — the sidebar and dashboard gate on it,
diff --git a/packages/api/src/middleware/procedures.ts b/packages/api/src/middleware/procedures.ts
index c04bd5bc..3b9341a0 100644
--- a/packages/api/src/middleware/procedures.ts
+++ b/packages/api/src/middleware/procedures.ts
@@ -1,6 +1,12 @@
import { TRPCError } from "@trpc/server";
import { protectedProcedure } from "../trpc";
-import { admins, judges, judgingProjects, judgeQueue } from "@query/db";
+import {
+ admins,
+ judges,
+ judgingProjects,
+ judgeQueue,
+ projectLeaders,
+} from "@query/db";
import { eq, and } from "drizzle-orm";
import { CacheKeys } from "./cache";
import { resolveHackathonId } from "../services/portal-context";
@@ -76,6 +82,65 @@ export const isSuperAdmin = isAdmin.use(async ({ ctx, next }) => {
return next({ ctx });
});
+/**
+ * Verifies the caller runs initiatives for the current hackathon.
+ *
+ * Admins pass without a project_leader row: staff cover for a leader who has
+ * gone quiet. The reverse is deliberately not true — this grants nothing under
+ * isAdmin. Holding the role is only half the gate; every procedure that touches
+ * one initiative also checks who leads it, and an admin is the only caller
+ * allowed to skip that.
+ */
+export const isProjectLeader = protectedProcedure.use(async ({ ctx, next }) => {
+ const db = ctx.db as NonNullable;
+ const userId = ctx.userId as string;
+
+ const hackathonId = await resolveHackathonId(db);
+ if (!hackathonId) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "No hackathon context found",
+ });
+ }
+
+ const cacheKey = `${CacheKeys.projectLeader(userId)}:${hackathonId}:role`;
+ let leader = ctx.cache.get(cacheKey);
+
+ if (!leader) {
+ leader =
+ (await db.query.projectLeaders.findFirst({
+ where: and(
+ eq(projectLeaders.userId, userId),
+ eq(projectLeaders.hackathonId, hackathonId),
+ eq(projectLeaders.isActive, true),
+ ),
+ })) ?? null;
+
+ if (leader) ctx.cache.set(cacheKey, leader, 60);
+ }
+
+ // Resolved even when a leader row exists: somebody can be both, and the
+ // ownership checks downstream need to know whether to let them past another
+ // leader's initiative. callerIsAdmin caches both answers, so this is cheap.
+ const isPlatformAdmin = await callerIsAdmin(ctx);
+
+ if (!leader && !isPlatformAdmin) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Project leader access required",
+ });
+ }
+
+ return next({
+ ctx: {
+ ...ctx,
+ hackathonId,
+ projectLeader: leader ?? null,
+ isPlatformAdmin,
+ },
+ });
+});
+
/**
* Middleware that verifies the current user is an active judge for a specific hackathon.
* Result is cached for 60s per user per hackathon to avoid a DB round-trip on every request.
diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts
index 47909997..a0b24993 100644
--- a/packages/api/src/root.ts
+++ b/packages/api/src/root.ts
@@ -9,6 +9,7 @@ import { judgeRouter } from "./routers/judge";
import { stripeRouter } from "./routers/stripe";
import { auditRouter } from "./routers/audit";
import { teamRouter } from "./routers/team";
+import { initiativeRouter } from "./routers/initiative";
export const appRouter = createTRPCRouter({
hello: helloRouter,
@@ -21,6 +22,7 @@ export const appRouter = createTRPCRouter({
stripe: stripeRouter,
audit: auditRouter,
team: teamRouter,
+ initiative: initiativeRouter,
});
export type AppRouter = typeof appRouter;
diff --git a/packages/api/src/routers/initiative.ts b/packages/api/src/routers/initiative.ts
new file mode 100644
index 00000000..d1ea78d2
--- /dev/null
+++ b/packages/api/src/routers/initiative.ts
@@ -0,0 +1,749 @@
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { and, asc, count, desc, eq, inArray, isNull, ne } from "drizzle-orm";
+import {
+ initiativeApplications,
+ initiatives,
+ members,
+ projectLeaders,
+ users,
+} from "@query/db";
+import type { DrizzleDB, Initiative } from "@query/db";
+import { createTRPCRouter, protectedProcedure } from "../trpc";
+import { isAdmin, isProjectLeader } from "../middleware/procedures";
+import { clearProjectLeaderCaches } from "../middleware/cache";
+import { resolveHackathonId } from "../services/portal-context";
+
+const notFound = (message = "Initiative not found") =>
+ new TRPCError({ code: "NOT_FOUND", message });
+
+/** Postgres unique_violation. Drizzle wraps driver errors, so walk `.cause`. */
+function isUniqueViolation(error: unknown) {
+ for (let cursor: unknown = error, depth = 0; cursor && depth < 5; depth++) {
+ if (typeof cursor !== "object") break;
+ if ((cursor as { code?: string }).code === "23505") return true;
+ cursor = (cursor as { cause?: unknown }).cause;
+ }
+ return false;
+}
+
+/** What `db.transaction(async (tx) => …)` hands its callback. */
+type Tx = Parameters[0]>[0];
+/** The helpers below only read, so either handle will do. */
+type Reader = DrizzleDB | Tx;
+
+const initiativeInput = z.object({
+ title: z.string().trim().min(1).max(200),
+ summary: z.string().trim().max(300).optional(),
+ description: z.string().trim().max(4000).optional(),
+ commitment: z.string().trim().max(120).optional(),
+ maxMembers: z.number().int().positive().max(500).nullable().optional(),
+});
+
+/**
+ * Admins manage every initiative; a leader manages only their own. Callers
+ * turn a false into NOT_FOUND rather than FORBIDDEN, so a leader who guesses
+ * another leader's id does not learn from the error that it exists.
+ */
+function canManage(
+ ctx: { userId: string; isPlatformAdmin: boolean },
+ initiative: Initiative,
+) {
+ return ctx.isPlatformAdmin || initiative.leaderUserId === ctx.userId;
+}
+
+/** Applying is a member benefit, so it needs a membership that has not lapsed. */
+async function requireActiveMember(
+ db: Reader,
+ userId: string,
+ hackathonId: string,
+) {
+ const member = await db.query.members.findFirst({
+ where: and(eq(members.userId, userId), eq(members.hackathonId, hackathonId)),
+ columns: { isActive: true, membershipEndDate: true },
+ });
+
+ const active = !!(
+ member?.isActive &&
+ member.membershipEndDate &&
+ member.membershipEndDate > new Date()
+ );
+
+ if (!active) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "An active membership is required to join an initiative.",
+ });
+ }
+}
+
+/** Exact because every writer locks the initiative row before counting. */
+async function acceptedSeats(tx: Reader, initiativeId: string) {
+ const [row] = await tx
+ .select({ taken: count() })
+ .from(initiativeApplications)
+ .where(
+ and(
+ eq(initiativeApplications.initiativeId, initiativeId),
+ eq(initiativeApplications.status, "accepted"),
+ ),
+ );
+ return row?.taken ?? 0;
+}
+
+/** Serialises decisions on one initiative so a cap with one seat left holds. */
+function lockInitiative(tx: Reader, id: string) {
+ return tx
+ .select({ id: initiatives.id })
+ .from(initiatives)
+ .where(eq(initiatives.id, id))
+ .for("update");
+}
+
+export const initiativeRouter = createTRPCRouter({
+ // ------------------------------------------------------------------ leader
+
+ listMine: isProjectLeader.query(async ({ ctx }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const rows = await db
+ .select({
+ id: initiatives.id,
+ title: initiatives.title,
+ summary: initiatives.summary,
+ // The edit form prefills from this row, and `update` writes an explicit
+ // null for anything omitted — so a field missing here is a field the
+ // first save silently clears.
+ description: initiatives.description,
+ commitment: initiatives.commitment,
+ status: initiatives.status,
+ maxMembers: initiatives.maxMembers,
+ archivedAt: initiatives.archivedAt,
+ leaderUserId: initiatives.leaderUserId,
+ leaderName: users.name,
+ createdAt: initiatives.createdAt,
+ })
+ .from(initiatives)
+ .innerJoin(users, eq(users.id, initiatives.leaderUserId))
+ .where(
+ and(
+ eq(initiatives.hackathonId, ctx.hackathonId),
+ ctx.isPlatformAdmin
+ ? undefined
+ : eq(initiatives.leaderUserId, ctx.userId),
+ ),
+ )
+ .orderBy(desc(initiatives.createdAt))
+ .limit(200);
+
+ if (rows.length === 0) return [];
+
+ const tallies = await db
+ .select({
+ initiativeId: initiativeApplications.initiativeId,
+ status: initiativeApplications.status,
+ total: count(),
+ })
+ .from(initiativeApplications)
+ .where(
+ inArray(
+ initiativeApplications.initiativeId,
+ rows.map((row) => row.id),
+ ),
+ )
+ .groupBy(
+ initiativeApplications.initiativeId,
+ initiativeApplications.status,
+ );
+
+ const byInitiative = new Map();
+ for (const tally of tallies) {
+ const entry = byInitiative.get(tally.initiativeId) ?? {
+ pending: 0,
+ accepted: 0,
+ };
+ if (tally.status === "pending") entry.pending = tally.total;
+ if (tally.status === "accepted") entry.accepted = tally.total;
+ byInitiative.set(tally.initiativeId, entry);
+ }
+
+ return rows.map((row) => ({
+ ...row,
+ pending: byInitiative.get(row.id)?.pending ?? 0,
+ accepted: byInitiative.get(row.id)?.accepted ?? 0,
+ isMine: row.leaderUserId === ctx.userId,
+ }));
+ }),
+
+ getById: isProjectLeader
+ .input(z.object({ id: z.string().uuid() }))
+ .query(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const initiative = await db.query.initiatives.findFirst({
+ where: eq(initiatives.id, input.id),
+ });
+ if (!initiative || !canManage(ctx, initiative)) throw notFound();
+
+ const applicants = await db
+ .select({
+ userId: initiativeApplications.userId,
+ name: users.name,
+ email: users.email,
+ image: users.image,
+ status: initiativeApplications.status,
+ pitch: initiativeApplications.pitch,
+ appliedAt: initiativeApplications.appliedAt,
+ decidedAt: initiativeApplications.decidedAt,
+ })
+ .from(initiativeApplications)
+ .innerJoin(users, eq(users.id, initiativeApplications.userId))
+ .where(eq(initiativeApplications.initiativeId, initiative.id))
+ // Oldest first: a leader works the queue in the order hands went up.
+ .orderBy(asc(initiativeApplications.appliedAt));
+
+ return {
+ initiative,
+ applicants,
+ accepted: applicants.filter((row) => row.status === "accepted").length,
+ };
+ }),
+
+ create: isProjectLeader
+ .input(initiativeInput)
+ .mutation(async ({ ctx, input }) => {
+ const [created] = await (ctx.db as DrizzleDB)
+ .insert(initiatives)
+ .values({
+ hackathonId: ctx.hackathonId,
+ leaderUserId: ctx.userId,
+ title: input.title,
+ summary: input.summary ?? null,
+ description: input.description ?? null,
+ commitment: input.commitment ?? null,
+ maxMembers: input.maxMembers ?? null,
+ // Nothing reaches members until the leader opens it.
+ status: "draft",
+ })
+ .returning();
+
+ if (!created) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Could not create that initiative.",
+ });
+ }
+ return created;
+ }),
+
+ update: isProjectLeader
+ .input(initiativeInput.extend({ id: z.string().uuid() }))
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+ const { id, ...fields } = input;
+
+ const initiative = await db.query.initiatives.findFirst({
+ where: eq(initiatives.id, id),
+ });
+ if (!initiative || !canManage(ctx, initiative)) throw notFound();
+
+ // Every nullable column reaches .set() as an explicit null: drizzle drops
+ // undefined from the update entirely, so clearing a summary would report
+ // success and change nothing.
+ const [updated] = await db
+ .update(initiatives)
+ .set({
+ title: fields.title,
+ summary: fields.summary ?? null,
+ description: fields.description ?? null,
+ commitment: fields.commitment ?? null,
+ maxMembers: fields.maxMembers ?? null,
+ updatedAt: new Date(),
+ })
+ .where(eq(initiatives.id, id))
+ .returning();
+
+ if (!updated) throw notFound();
+ return updated;
+ }),
+
+ /**
+ * Closing decides nothing — applications already queued can still be
+ * accepted, which is what a leader with enough applicants wants. Lowering the
+ * cap below the accepted count is likewise left alone: nobody is thrown off
+ * by an edit to a number.
+ */
+ setStatus: isProjectLeader
+ .input(
+ z.object({
+ id: z.string().uuid(),
+ status: z.enum(["draft", "open", "closed"]),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const initiative = await db.query.initiatives.findFirst({
+ where: eq(initiatives.id, input.id),
+ });
+ if (!initiative || !canManage(ctx, initiative)) throw notFound();
+
+ // An archived initiative is hidden from members whatever the status says.
+ if (initiative.archivedAt !== null) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Restore this initiative before changing its status.",
+ });
+ }
+
+ const [updated] = await db
+ .update(initiatives)
+ .set({ status: input.status, updatedAt: new Date() })
+ .where(eq(initiatives.id, input.id))
+ .returning({ id: initiatives.id, status: initiatives.status });
+
+ if (!updated) throw notFound();
+ return updated;
+ }),
+
+ setArchived: isProjectLeader
+ .input(z.object({ id: z.string().uuid(), archived: z.boolean() }))
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const initiative = await db.query.initiatives.findFirst({
+ where: eq(initiatives.id, input.id),
+ });
+ if (!initiative || !canManage(ctx, initiative)) throw notFound();
+
+ const [updated] = await db
+ .update(initiatives)
+ .set({
+ archivedAt: input.archived ? new Date() : null,
+ // Archiving shuts the door too, so restoring later does not silently
+ // re-open applications nobody decided to re-open.
+ status: input.archived ? "closed" : initiative.status,
+ updatedAt: new Date(),
+ })
+ .where(eq(initiatives.id, input.id))
+ .returning({
+ id: initiatives.id,
+ archivedAt: initiatives.archivedAt,
+ });
+
+ if (!updated) throw notFound();
+ return updated;
+ }),
+
+ /**
+ * Reversible both ways: a rejection can be taken back, an acceptance can be
+ * revoked and the seat returns. The one refused transition is deciding on
+ * somebody who withdrew.
+ */
+ decide: isProjectLeader
+ .input(
+ z.object({
+ initiativeId: z.string().uuid(),
+ userId: z.string(),
+ decision: z.enum(["accepted", "rejected"]),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ return (ctx.db as DrizzleDB).transaction(async (tx) => {
+ const initiative = await tx.query.initiatives.findFirst({
+ where: eq(initiatives.id, input.initiativeId),
+ });
+ if (!initiative || !canManage(ctx, initiative)) throw notFound();
+
+ await lockInitiative(tx, initiative.id);
+
+ const application = await tx.query.initiativeApplications.findFirst({
+ where: and(
+ eq(initiativeApplications.initiativeId, initiative.id),
+ eq(initiativeApplications.userId, input.userId),
+ ),
+ });
+ if (!application) throw notFound("That member has not applied.");
+
+ if (application.status === "withdrawn") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "They withdrew their application.",
+ });
+ }
+
+ // Two leaders clicking the same button: the second is a no-op, so
+ // decidedAt keeps pointing at the real decision.
+ if (application.status === input.decision) {
+ return { status: application.status };
+ }
+
+ if (input.decision === "accepted" && initiative.maxMembers !== null) {
+ const taken = await acceptedSeats(tx, initiative.id);
+ if (taken >= initiative.maxMembers) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "This initiative is full.",
+ });
+ }
+ }
+
+ await tx
+ .update(initiativeApplications)
+ .set({
+ status: input.decision,
+ decidedAt: new Date(),
+ decidedById: ctx.userId,
+ })
+ .where(eq(initiativeApplications.id, application.id));
+
+ return { status: input.decision };
+ });
+ }),
+
+ // ------------------------------------------------------------------ member
+
+ /**
+ * Visible to any signed-in user, not just paid members: somebody deciding
+ * whether to join should be able to see what they would get. Applying is
+ * where the membership check bites.
+ */
+ list: protectedProcedure
+ .input(z.object({ hackathonId: z.string().uuid().optional() }).optional())
+ .query(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+ const hackathonId = await resolveHackathonId(db, input?.hackathonId);
+ if (!hackathonId) return [];
+
+ const open = await db
+ .select({
+ id: initiatives.id,
+ title: initiatives.title,
+ summary: initiatives.summary,
+ description: initiatives.description,
+ commitment: initiatives.commitment,
+ status: initiatives.status,
+ maxMembers: initiatives.maxMembers,
+ archivedAt: initiatives.archivedAt,
+ leaderName: users.name,
+ leaderImage: users.image,
+ })
+ .from(initiatives)
+ .innerJoin(users, eq(users.id, initiatives.leaderUserId))
+ .where(
+ and(
+ eq(initiatives.hackathonId, hackathonId),
+ eq(initiatives.status, "open"),
+ isNull(initiatives.archivedAt),
+ ),
+ )
+ .orderBy(asc(initiatives.title))
+ .limit(60);
+
+ if (open.length === 0) return [];
+
+ const ids = open.map((row) => row.id);
+
+ const [seats, mine] = await Promise.all([
+ db
+ .select({
+ initiativeId: initiativeApplications.initiativeId,
+ taken: count(),
+ })
+ .from(initiativeApplications)
+ .where(
+ and(
+ inArray(initiativeApplications.initiativeId, ids),
+ eq(initiativeApplications.status, "accepted"),
+ ),
+ )
+ .groupBy(initiativeApplications.initiativeId),
+ db
+ .select({
+ initiativeId: initiativeApplications.initiativeId,
+ status: initiativeApplications.status,
+ })
+ .from(initiativeApplications)
+ .where(
+ and(
+ inArray(initiativeApplications.initiativeId, ids),
+ eq(initiativeApplications.userId, ctx.userId),
+ ),
+ ),
+ ]);
+
+ const taken = new Map(seats.map((row) => [row.initiativeId, row.taken]));
+ const status = new Map(mine.map((row) => [row.initiativeId, row.status]));
+
+ return open.map((row) => {
+ const accepted = taken.get(row.id) ?? 0;
+ const myStatus = status.get(row.id) ?? null;
+ return {
+ ...row,
+ accepted,
+ // withdrawn reads as no application, because re-applying is allowed.
+ myStatus: myStatus === "withdrawn" ? null : myStatus,
+ isFull: row.maxMembers !== null && accepted >= row.maxMembers,
+ };
+ });
+ }),
+
+ myApplications: protectedProcedure
+ .input(z.object({ hackathonId: z.string().uuid().optional() }).optional())
+ .query(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+ const hackathonId = await resolveHackathonId(db, input?.hackathonId);
+ if (!hackathonId) return [];
+
+ const rows = await db
+ .select({
+ id: initiatives.id,
+ title: initiatives.title,
+ summary: initiatives.summary,
+ status: initiatives.status,
+ maxMembers: initiatives.maxMembers,
+ archivedAt: initiatives.archivedAt,
+ leaderName: users.name,
+ leaderEmail: users.email,
+ myStatus: initiativeApplications.status,
+ appliedAt: initiativeApplications.appliedAt,
+ decidedAt: initiativeApplications.decidedAt,
+ })
+ .from(initiativeApplications)
+ .innerJoin(
+ initiatives,
+ eq(initiatives.id, initiativeApplications.initiativeId),
+ )
+ .innerJoin(users, eq(users.id, initiatives.leaderUserId))
+ .where(
+ and(
+ eq(initiativeApplications.userId, ctx.userId),
+ eq(initiatives.hackathonId, hackathonId),
+ // A withdrawal is an exit, not a record to carry forever.
+ ne(initiativeApplications.status, "withdrawn"),
+ ),
+ )
+ .orderBy(desc(initiativeApplications.appliedAt))
+ .limit(60);
+
+ // The leader's address is contact detail for people actually on the
+ // initiative. Stripped here, not in the component — what the component
+ // does not render still rides along in the payload.
+ return rows.map(({ leaderEmail, ...row }) => ({
+ ...row,
+ leaderEmail: row.myStatus === "accepted" ? leaderEmail : null,
+ }));
+ }),
+
+ /**
+ * Not `apply`: tRPC refuses a procedure named after anything on
+ * Function.prototype and throws at router construction, taking the whole API
+ * route down rather than just this procedure.
+ */
+ requestToJoin: protectedProcedure
+ .input(
+ z.object({
+ initiativeId: z.string().uuid(),
+ pitch: z.string().trim().max(1000).optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+ const userId = ctx.userId;
+ const pitch = input.pitch?.length ? input.pitch : null;
+
+ return db.transaction(async (tx) => {
+ const initiative = await tx.query.initiatives.findFirst({
+ where: eq(initiatives.id, input.initiativeId),
+ });
+ if (!initiative) throw notFound();
+
+ // A draft or archived initiative is invisible to members, so it answers
+ // exactly the way a made-up id does.
+ if (initiative.archivedAt !== null || initiative.status === "draft") {
+ throw notFound();
+ }
+
+ await requireActiveMember(tx, userId, initiative.hackathonId);
+
+ if (initiative.status !== "open") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "This initiative is not taking applications.",
+ });
+ }
+
+ if (initiative.leaderUserId === userId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "You already lead this initiative.",
+ });
+ }
+
+ await lockInitiative(tx, initiative.id);
+
+ const existing = await tx.query.initiativeApplications.findFirst({
+ where: and(
+ eq(initiativeApplications.initiativeId, initiative.id),
+ eq(initiativeApplications.userId, userId),
+ ),
+ });
+
+ // Tested before capacity: somebody who already applied is a duplicate,
+ // not an extra body, so they are told where they stand.
+ if (existing && existing.status !== "withdrawn") {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message:
+ existing.status === "pending"
+ ? "You have already applied to this initiative."
+ : existing.status === "accepted"
+ ? "You are already on this initiative."
+ : "The leader has already decided on your application.",
+ });
+ }
+
+ if (initiative.maxMembers !== null) {
+ const taken = await acceptedSeats(tx, initiative.id);
+ if (taken >= initiative.maxMembers) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "This initiative is full.",
+ });
+ }
+ }
+
+ if (existing) {
+ // Re-applying reuses the row the unique index already holds, and
+ // clears the stale decision with it.
+ await tx
+ .update(initiativeApplications)
+ .set({
+ status: "pending",
+ pitch,
+ appliedAt: new Date(),
+ decidedAt: null,
+ decidedById: null,
+ })
+ .where(eq(initiativeApplications.id, existing.id));
+ return { status: "pending" as const };
+ }
+
+ try {
+ await tx.insert(initiativeApplications).values({
+ initiativeId: initiative.id,
+ userId,
+ pitch,
+ status: "pending",
+ });
+ } catch (error) {
+ // The read above only rules out rows committed before this
+ // transaction began; the unique index settles a true double submit.
+ if (isUniqueViolation(error)) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "You have already applied to this initiative.",
+ });
+ }
+ throw error;
+ }
+
+ return { status: "pending" as const };
+ });
+ }),
+
+ withdraw: protectedProcedure
+ .input(z.object({ initiativeId: z.string().uuid() }))
+ .mutation(async ({ ctx, input }) => {
+ const [updated] = await (ctx.db as DrizzleDB)
+ .update(initiativeApplications)
+ .set({ status: "withdrawn", decidedAt: null, decidedById: null })
+ .where(
+ and(
+ eq(initiativeApplications.initiativeId, input.initiativeId),
+ eq(initiativeApplications.userId, ctx.userId),
+ // Makes a repeat call a genuine no-op.
+ ne(initiativeApplications.status, "withdrawn"),
+ ),
+ )
+ .returning({ id: initiativeApplications.id });
+
+ return { withdrawn: updated !== undefined };
+ }),
+
+ // ------------------------------------------------------------------- admin
+
+ listLeaders: isAdmin.query(async ({ ctx }) => {
+ const db = ctx.db as DrizzleDB;
+ const hackathonId = await resolveHackathonId(db);
+ if (!hackathonId) return [];
+
+ return db
+ .select({
+ id: projectLeaders.id,
+ userId: projectLeaders.userId,
+ name: users.name,
+ email: users.email,
+ image: users.image,
+ isActive: projectLeaders.isActive,
+ createdAt: projectLeaders.createdAt,
+ })
+ .from(projectLeaders)
+ .innerJoin(users, eq(users.id, projectLeaders.userId))
+ .where(eq(projectLeaders.hackathonId, hackathonId))
+ .orderBy(asc(users.email))
+ .limit(200);
+ }),
+
+ /**
+ * Grant or revoke, by user id, for the current edition. Upserted rather than
+ * deleted so an appointment stays on the record after it is revoked.
+ */
+ setLeader: isAdmin
+ .input(z.object({ userId: z.string(), isLeader: z.boolean() }))
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+ const hackathonId = await resolveHackathonId(db);
+ if (!hackathonId) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "No hackathon context found",
+ });
+ }
+
+ const target = await db.query.users.findFirst({
+ where: eq(users.id, input.userId),
+ columns: { id: true },
+ });
+ if (!target) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
+ }
+
+ const existing = await db.query.projectLeaders.findFirst({
+ where: and(
+ eq(projectLeaders.userId, input.userId),
+ eq(projectLeaders.hackathonId, hackathonId),
+ ),
+ });
+
+ if (existing) {
+ await db
+ .update(projectLeaders)
+ .set({ isActive: input.isLeader, updatedAt: new Date() })
+ .where(eq(projectLeaders.id, existing.id));
+ } else if (input.isLeader) {
+ await db.insert(projectLeaders).values({
+ userId: input.userId,
+ hackathonId,
+ isActive: true,
+ appointedBy: ctx.userId,
+ });
+ }
+
+ // The gate caches for 60s and the sidebar reads the portal context; both
+ // have to go or the change does not show up until they expire.
+ clearProjectLeaderCaches(input.userId);
+
+ return { userId: input.userId, isLeader: input.isLeader };
+ }),
+});
diff --git a/packages/api/src/services/portal-context.ts b/packages/api/src/services/portal-context.ts
index 0d6e75c5..542d98ed 100644
--- a/packages/api/src/services/portal-context.ts
+++ b/packages/api/src/services/portal-context.ts
@@ -1,4 +1,4 @@
-import { admins, members, judges } from "@query/db";
+import { admins, members, judges, projectLeaders } from "@query/db";
// Deep import on purpose: this is the one rule for "which hackathon is
// current", shared with the sign-in hook in @query/auth.
import {
@@ -99,15 +99,28 @@ export async function fetchPortalContext(
]);
let member = EMPTY_MEMBER_CONTEXT;
+ let isProjectLeader = false;
+ // Both are scoped to the edition, so neither can be read until it resolves.
if (hackathonId) {
- const memberRecord = await db.query.members.findFirst({
- where: and(
- eq(members.userId, userId),
- eq(members.hackathonId, hackathonId),
- ),
- });
+ const [memberRecord, leaderRecord] = await Promise.all([
+ db.query.members.findFirst({
+ where: and(
+ eq(members.userId, userId),
+ eq(members.hackathonId, hackathonId),
+ ),
+ }),
+ db.query.projectLeaders.findFirst({
+ where: and(
+ eq(projectLeaders.userId, userId),
+ eq(projectLeaders.hackathonId, hackathonId),
+ eq(projectLeaders.isActive, true),
+ ),
+ columns: { id: true },
+ }),
+ ]);
member = buildMemberContext(memberRecord ?? null);
+ isProjectLeader = !!leaderRecord;
}
return {
@@ -117,6 +130,10 @@ export async function fetchPortalContext(
isJudge: !!judgeRecord,
judgeId: judgeRecord?.id ?? null,
judgeName: judgeRecord?.name ?? null,
+ // Admins cover for leaders, and the middleware agrees — so the tab has to
+ // appear for them too or staff see a page they are allowed to use but
+ // cannot reach.
+ isProjectLeader: isProjectLeader || !!admin,
member,
};
}
diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts
index beabb76e..92e4f059 100644
--- a/packages/api/src/trpc.ts
+++ b/packages/api/src/trpc.ts
@@ -282,6 +282,17 @@ const CACHE_INVALIDATION_MAP: Record = {
// Stripe — invalidate member status after linking
"stripe.attemptAutoLink": ["member:*"],
"stripe.linkAccount": ["member:*"],
+ // Initiatives. Every write moves what BOTH the member list and the leader's
+ // queue show, so neither namespace can be evicted on its own.
+ "initiative.create": ["initiative:*"],
+ "initiative.update": ["initiative:*"],
+ "initiative.setStatus": ["initiative:*"],
+ "initiative.setArchived": ["initiative:*"],
+ "initiative.decide": ["initiative:*"],
+ "initiative.requestToJoin": ["initiative:*"],
+ "initiative.withdraw": ["initiative:*"],
+ // setLeader clears the role gate and portal context itself, by user id.
+ "initiative.setLeader": ["initiative:*"],
// Events (club check-ins)
"events.create": ["events:list"],
"events.delete": ["events:list"],
diff --git a/packages/api/src/types/portal-context.ts b/packages/api/src/types/portal-context.ts
index b62a62fc..752f71bf 100644
--- a/packages/api/src/types/portal-context.ts
+++ b/packages/api/src/types/portal-context.ts
@@ -14,6 +14,8 @@ export type PortalContext = {
isJudge: boolean;
judgeId: string | null;
judgeName: string | null;
+ /** Runs club initiatives for the current edition. Not a staff role. */
+ isProjectLeader: boolean;
member: MemberContext;
};
diff --git a/packages/db/src/schemas/index.ts b/packages/db/src/schemas/index.ts
index 07163c4a..daba0e93 100644
--- a/packages/db/src/schemas/index.ts
+++ b/packages/db/src/schemas/index.ts
@@ -5,6 +5,7 @@ export * from "./hackathons";
export * from "./admins";
export * from "./events";
export * from "./judge";
+export * from "./initiatives";
export * from "./stripe";
export * from "./security";
export * from "./settings";
diff --git a/packages/db/src/schemas/initiatives.ts b/packages/db/src/schemas/initiatives.ts
new file mode 100644
index 00000000..f7c1ddd4
--- /dev/null
+++ b/packages/db/src/schemas/initiatives.ts
@@ -0,0 +1,181 @@
+import {
+ pgTable,
+ text,
+ timestamp,
+ uuid,
+ boolean,
+ integer,
+ index,
+ unique,
+} from "drizzle-orm/pg-core";
+import { relations } from "drizzle-orm";
+import { users } from "./auth";
+import { hackathons } from "./hackathons";
+
+/**
+ * Club initiatives: things a project leader runs year-round that members apply
+ * to join. Named `initiative` rather than `project` because a hackathon
+ * "project" is already a judged submission, and one word for both would make
+ * every query and conversation ambiguous.
+ */
+
+/**
+ * The project-leader role, as its own assignment table rather than a value on
+ * `admin.role` — a leader is an elevated member, not staff, and nothing here
+ * should widen an existing admin check. Scoped per hackathon edition, the same
+ * way `judge` and `member` are.
+ */
+export const projectLeaders = pgTable(
+ "project_leader",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ hackathonId: uuid("hackathon_id")
+ .notNull()
+ .references(() => hackathons.id, { onDelete: "cascade" }),
+ /** Revoked by clearing this, so the appointment stays on the record. */
+ isActive: boolean("is_active").notNull().default(true),
+ appointedBy: text("appointed_by").references(() => users.id, {
+ onDelete: "set null",
+ }),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+ },
+ (table) => [
+ index("project_leader_user_id_idx").on(table.userId),
+ index("project_leader_hackathon_id_idx").on(table.hackathonId),
+ unique("unique_project_leader_per_hackathon").on(
+ table.userId,
+ table.hackathonId,
+ ),
+ ],
+);
+
+export type ProjectLeader = typeof projectLeaders.$inferSelect;
+
+/** draft is invisible to members, open takes applications, closed stops them. */
+export const initiativeStatuses = ["draft", "open", "closed"] as const;
+export type InitiativeStatus = (typeof initiativeStatuses)[number];
+
+/**
+ * No accepted-seat counter here on purpose: every writer takes a row lock on
+ * the initiative first, so the accepted rows are counted directly and there is
+ * no second number that can drift.
+ *
+ * `leaderUserId` points at the user, not at `project_leader.id`, so revoking
+ * somebody's role leaves their initiatives intact and still attributable.
+ */
+export const initiatives = pgTable(
+ "initiative",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+ hackathonId: uuid("hackathon_id")
+ .notNull()
+ .references(() => hackathons.id, { onDelete: "cascade" }),
+ leaderUserId: text("leader_user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ title: text("title").notNull(),
+ summary: text("summary"),
+ description: text("description"),
+ commitment: text("commitment"),
+ status: text("status", { enum: initiativeStatuses })
+ .notNull()
+ .default("draft"),
+ /** Null means uncapped. Zero would be an initiative nobody can join. */
+ maxMembers: integer("max_members"),
+ archivedAt: timestamp("archived_at"),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+ },
+ (table) => [
+ index("initiative_hackathon_id_idx").on(table.hackathonId),
+ index("initiative_leader_idx").on(table.leaderUserId),
+ index("initiative_status_idx").on(table.status),
+ ],
+);
+
+export type Initiative = typeof initiatives.$inferSelect;
+
+export const applicationStatuses = [
+ "pending",
+ "accepted",
+ "rejected",
+ "withdrawn",
+] as const;
+export type ApplicationStatus = (typeof applicationStatuses)[number];
+
+/**
+ * `withdrawn` is a state rather than a deleted row: the unique index is what
+ * stops a double submission, and it has to keep holding while somebody is gone
+ * so re-applying reuses the row instead of racing a second insert against it.
+ */
+export const initiativeApplications = pgTable(
+ "initiative_application",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+ initiativeId: uuid("initiative_id")
+ .notNull()
+ .references(() => initiatives.id, { onDelete: "cascade" }),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ status: text("status", { enum: applicationStatuses })
+ .notNull()
+ .default("pending"),
+ pitch: text("pitch"),
+ /** Re-stamped on re-apply, so the leader's queue is ordered by when the
+ * hand actually went up. */
+ appliedAt: timestamp("applied_at").defaultNow().notNull(),
+ decidedAt: timestamp("decided_at"),
+ decidedById: text("decided_by_id").references(() => users.id, {
+ onDelete: "set null",
+ }),
+ },
+ (table) => [
+ index("initiative_application_initiative_idx").on(table.initiativeId),
+ index("initiative_application_user_idx").on(table.userId),
+ unique("unique_application_per_initiative").on(
+ table.initiativeId,
+ table.userId,
+ ),
+ ],
+);
+
+export type InitiativeApplication = typeof initiativeApplications.$inferSelect;
+
+export const projectLeadersRelations = relations(projectLeaders, ({ one }) => ({
+ user: one(users, { fields: [projectLeaders.userId], references: [users.id] }),
+ hackathon: one(hackathons, {
+ fields: [projectLeaders.hackathonId],
+ references: [hackathons.id],
+ }),
+}));
+
+export const initiativesRelations = relations(initiatives, ({ one, many }) => ({
+ leader: one(users, {
+ fields: [initiatives.leaderUserId],
+ references: [users.id],
+ }),
+ hackathon: one(hackathons, {
+ fields: [initiatives.hackathonId],
+ references: [hackathons.id],
+ }),
+ applications: many(initiativeApplications),
+}));
+
+export const initiativeApplicationsRelations = relations(
+ initiativeApplications,
+ ({ one }) => ({
+ initiative: one(initiatives, {
+ fields: [initiativeApplications.initiativeId],
+ references: [initiatives.id],
+ }),
+ user: one(users, {
+ fields: [initiativeApplications.userId],
+ references: [users.id],
+ }),
+ }),
+);
diff --git a/sites/mainweb/app/(portal)/admin/initiatives/page.tsx b/sites/mainweb/app/(portal)/admin/initiatives/page.tsx
new file mode 100644
index 00000000..9a094a47
--- /dev/null
+++ b/sites/mainweb/app/(portal)/admin/initiatives/page.tsx
@@ -0,0 +1,150 @@
+"use client";
+
+import { useState } from "react";
+import { useSession } from "next-auth/react";
+import { Rocket } from "lucide-react";
+import { LiquidGlass } from "@/components/portal/LiquidGlass";
+import { LoadingScreen } from "@/components/portal/LoadingScreen";
+import { trpc } from "@/lib/trpc";
+
+/**
+ * Who runs initiatives this edition.
+ *
+ * Granting takes a user id rather than an email search: this reuses the
+ * attendees list every officer already works from, and a leader has to have
+ * signed in at least once to have an id at all.
+ */
+export default function AdminInitiativesPage() {
+ const { data: session, status } = useSession();
+ const utils = trpc.useUtils();
+ const [userId, setUserId] = useState("");
+
+ const leaders = trpc.initiative.listLeaders.useQuery(undefined, {
+ enabled: !!session,
+ });
+
+ const setLeader = trpc.initiative.setLeader.useMutation({
+ onSuccess: async () => {
+ setUserId("");
+ await utils.initiative.listLeaders.invalidate();
+ },
+ });
+
+ if (status === "loading" || leaders.isPending) return ;
+
+ if (leaders.error) {
+ return (
+
+
+ {leaders.error.message}
+
+
+ );
+ }
+
+ const rows = leaders.data ?? [];
+
+ return (
+
+
+
+
+
+
+ {setLeader.error && (
+ {setLeader.error.message}
+ )}
+
+
+
+
+ Leaders this edition
+
+
+ {rows.length > 0 ? (
+
+ {rows.map((leader) => (
+
+
+
+
+ {leader.name ?? leader.email}
+ {!leader.isActive && (
+
+ revoked
+
+ )}
+
+
+ {leader.email}
+
+
+
+
+ setLeader.mutate({
+ userId: leader.userId,
+ isLeader: !leader.isActive,
+ })
+ }
+ className="rounded-full border border-white/15 px-4 py-2 text-sm font-semibold text-white/80 transition hover:bg-white/5 disabled:opacity-50"
+ >
+ {leader.isActive ? "Revoke" : "Restore"}
+
+
+
+ ))}
+
+ ) : (
+
+ No leaders yet.
+
+ Grant the role above and it takes effect on their next request.
+
+
+ )}
+
+
+ );
+}
diff --git a/sites/mainweb/app/(portal)/initiatives/page.tsx b/sites/mainweb/app/(portal)/initiatives/page.tsx
new file mode 100644
index 00000000..4e37354f
--- /dev/null
+++ b/sites/mainweb/app/(portal)/initiatives/page.tsx
@@ -0,0 +1,322 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { useSession } from "next-auth/react";
+import { Rocket } from "lucide-react";
+import { LiquidGlass } from "@/components/portal/LiquidGlass";
+import { LoadingScreen } from "@/components/portal/LoadingScreen";
+import {
+ ApplicationChip,
+ seatLabel,
+} from "@/components/portal/initiatives/chips";
+import { trpc } from "@/lib/trpc";
+import type { RouterOutputs } from "@query/api";
+
+type OpenInitiative = RouterOutputs["initiative"]["list"][number];
+type MyApplication = RouterOutputs["initiative"]["myApplications"][number];
+
+function OpenRow({
+ initiative,
+ canApply,
+}: {
+ initiative: OpenInitiative;
+ canApply: boolean;
+}) {
+ const utils = trpc.useUtils();
+ const [writing, setWriting] = useState(false);
+ const [pitch, setPitch] = useState("");
+
+ // Both lists move together: applying takes an initiative out of one and puts
+ // it into the other, so refreshing one alone renders it twice.
+ const refresh = async () => {
+ await Promise.all([
+ utils.initiative.list.invalidate(),
+ utils.initiative.myApplications.invalidate(),
+ ]);
+ };
+
+ const join = trpc.initiative.requestToJoin.useMutation({
+ onSuccess: async () => {
+ setWriting(false);
+ setPitch("");
+ await refresh();
+ },
+ });
+
+ return (
+
+
+
+
{initiative.title}
+
+ Led by {initiative.leaderName ?? "a project leader"} ·{" "}
+ {seatLabel(initiative.accepted, initiative.maxMembers)}
+ {initiative.commitment ? ` · ${initiative.commitment}` : ""}
+
+
+
+ {initiative.isFull ? (
+
+ Full
+
+ ) : (
+ !writing &&
+ canApply && (
+
setWriting(true)}
+ className="shrink-0 rounded-full bg-white px-4 py-2 text-sm font-semibold text-black transition hover:bg-white/90"
+ >
+ Apply
+
+ )
+ )}
+
+
+ {initiative.summary && (
+ {initiative.summary}
+ )}
+ {initiative.description && (
+
+ {initiative.description}
+
+ )}
+
+ {!canApply && !initiative.isFull && (
+
+ An active membership is required to join.{" "}
+
+ Become a member
+
+
+ )}
+
+ {writing && (
+
+ )}
+
+ );
+}
+
+function ApplicationRow({ application }: { application: MyApplication }) {
+ const utils = trpc.useUtils();
+ const [confirm, setConfirm] = useState(false);
+
+ const withdraw = trpc.initiative.withdraw.useMutation({
+ onSuccess: async () => {
+ await Promise.all([
+ utils.initiative.list.invalidate(),
+ utils.initiative.myApplications.invalidate(),
+ ]);
+ },
+ });
+
+ // Nothing to leave once the leader has said no, and an archived initiative is
+ // over — the control would change nothing either way.
+ const canWithdraw =
+ application.myStatus !== "rejected" && application.archivedAt === null;
+
+ return (
+
+
+
+
+
{application.title}
+
+
+
+ Led by {application.leaderName ?? "a project leader"}
+ {application.archivedAt !== null ? " · archived" : ""}
+
+ {application.leaderEmail && (
+
+ Reach them at{" "}
+
+ {application.leaderEmail}
+
+
+ )}
+
+
+ {canWithdraw &&
+ (confirm ? (
+
+
+ {application.myStatus === "accepted" ? "Leave?" : "Withdraw?"}
+
+ {
+ withdraw.mutate({ initiativeId: application.id });
+ setConfirm(false);
+ }}
+ className="rounded-full bg-red-500/20 px-3 py-1.5 text-sm font-semibold text-red-200 transition hover:bg-red-500/30 disabled:opacity-50"
+ >
+ Yes
+
+ setConfirm(false)}
+ className="rounded-full border border-white/15 px-3 py-1.5 text-sm font-semibold text-white/80"
+ >
+ Cancel
+
+
+ ) : (
+
setConfirm(true)}
+ className="shrink-0 text-sm font-semibold text-white/50 transition hover:text-white"
+ >
+ {application.myStatus === "accepted" ? "Leave" : "Withdraw"}
+
+ ))}
+
+
+ {withdraw.error && (
+ {withdraw.error.message}
+ )}
+
+ );
+}
+
+export default function InitiativesPage() {
+ const { data: session, status } = useSession();
+
+ const open = trpc.initiative.list.useQuery(undefined, { enabled: !!session });
+ const mine = trpc.initiative.myApplications.useQuery(undefined, {
+ enabled: !!session,
+ });
+ const memberStatus = trpc.member.checkStatus.useQuery(undefined, {
+ enabled: !!session,
+ });
+
+ if (status === "loading" || open.isPending || mine.isPending) {
+ return ;
+ }
+
+ const applications = mine.data ?? [];
+ // Already applied belongs in the member's own list, not in the one offering
+ // them a chance to apply again.
+ const joinable = (open.data ?? []).filter((row) => row.myStatus === null);
+ const canApply = !!memberStatus.data?.isActive;
+
+ return (
+
+
+
+ {(open.error ?? mine.error) && (
+
+ {(open.error ?? mine.error)?.message}
+
+ )}
+
+ {applications.length > 0 && (
+
+
+ Your applications
+
+
+ {applications.map((application) => (
+
+ ))}
+
+
+ )}
+
+
+
+ Open to join
+
+ {joinable.length > 0 ? (
+
+ {joinable.map((initiative) => (
+
+ ))}
+
+ ) : (
+
+
+ {applications.length > 0
+ ? "Nothing else open right now."
+ : "No initiatives are taking applications."}
+
+
+ Leaders open these up through the year. Check back, or ask at a
+ general meeting what is being planned.
+
+
+ )}
+
+
+ );
+}
diff --git a/sites/mainweb/app/(portal)/lead/[id]/page.tsx b/sites/mainweb/app/(portal)/lead/[id]/page.tsx
new file mode 100644
index 00000000..ef81abb1
--- /dev/null
+++ b/sites/mainweb/app/(portal)/lead/[id]/page.tsx
@@ -0,0 +1,263 @@
+"use client";
+
+import { use, useState } from "react";
+import Link from "next/link";
+import { ChevronLeft } from "lucide-react";
+import { LiquidGlass } from "@/components/portal/LiquidGlass";
+import { LoadingScreen } from "@/components/portal/LoadingScreen";
+import {
+ ApplicationChip,
+ InitiativeChip,
+ initiativeState,
+ seatLabel,
+} from "@/components/portal/initiatives/chips";
+import { trpc } from "@/lib/trpc";
+import type { RouterOutputs } from "@query/api";
+import type { ApplicationStatus } from "@query/db";
+
+type Applicant = RouterOutputs["initiative"]["getById"]["applicants"][number];
+
+function ApplicantRow({
+ initiativeId,
+ applicant,
+ full,
+}: {
+ initiativeId: string;
+ applicant: Applicant;
+ full: boolean;
+}) {
+ const utils = trpc.useUtils();
+ const [confirmRemove, setConfirmRemove] = useState(false);
+
+ const decide = trpc.initiative.decide.useMutation({
+ onSuccess: async () => {
+ await Promise.all([
+ utils.initiative.getById.invalidate({ id: initiativeId }),
+ // The list row carries the pending count, so leaving it alone keeps
+ // offering a queue that is already empty.
+ utils.initiative.listMine.invalidate(),
+ ]);
+ },
+ });
+
+ const send = (decision: "accepted" | "rejected") =>
+ decide.mutate({ initiativeId, userId: applicant.userId, decision });
+
+ return (
+
+
+
+
+
+ {applicant.name ?? applicant.email}
+
+
+
+
+
+ {applicant.email}
+
+
+ {applicant.pitch && (
+
+ {applicant.pitch}
+
+ )}
+
+
+
+ {applicant.status === "pending" && (
+ <>
+ send("accepted")}
+ className="rounded-full bg-white px-4 py-2 text-sm font-semibold text-black transition hover:bg-white/90 disabled:opacity-40"
+ >
+ Accept
+
+ send("rejected")}
+ className="rounded-full border border-white/15 px-4 py-2 text-sm font-semibold text-white/80 transition hover:bg-white/5 disabled:opacity-40"
+ >
+ Reject
+
+ >
+ )}
+
+ {applicant.status === "rejected" && (
+ send("accepted")}
+ className="rounded-full border border-white/15 px-4 py-2 text-sm font-semibold text-white/80 transition hover:bg-white/5 disabled:opacity-40"
+ >
+ Accept after all
+
+ )}
+
+ {applicant.status === "accepted" &&
+ (confirmRemove ? (
+ <>
+ Take them off?
+ {
+ send("rejected");
+ setConfirmRemove(false);
+ }}
+ className="rounded-full bg-red-500/20 px-3 py-1.5 text-sm font-semibold text-red-200 transition hover:bg-red-500/30"
+ >
+ Yes
+
+ setConfirmRemove(false)}
+ className="rounded-full border border-white/15 px-3 py-1.5 text-sm font-semibold text-white/80"
+ >
+ Cancel
+
+ >
+ ) : (
+ setConfirmRemove(true)}
+ className="text-sm font-semibold text-white/50 transition hover:text-red-300"
+ >
+ Remove
+
+ ))}
+
+
+
+ {decide.error && (
+ {decide.error.message}
+ )}
+
+ );
+}
+
+function Group({
+ title,
+ rows,
+ render,
+}: {
+ title: string;
+ rows: Applicant[];
+ render: (applicant: Applicant) => React.ReactNode;
+}) {
+ if (rows.length === 0) return null;
+ return (
+
+
+ {title}
+ {rows.length}
+
+ {rows.map(render)}
+
+ );
+}
+
+export default function LeadInitiativePage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = use(params);
+ const detail = trpc.initiative.getById.useQuery({ id });
+
+ if (detail.isPending) return ;
+
+ if (detail.error) {
+ return (
+
+
+ {detail.error.message}
+
+ Back to your initiatives
+
+
+
+ );
+ }
+
+ const { initiative, applicants, accepted } = detail.data;
+ const state = initiativeState(initiative);
+ const full =
+ initiative.maxMembers !== null && accepted >= initiative.maxMembers;
+
+ const inState = (...wanted: ApplicationStatus[]) =>
+ applicants.filter((row) => wanted.includes(row.status));
+
+ const row = (applicant: Applicant) => (
+
+ );
+
+ return (
+
+
+
Back to your initiatives
+
+
+
+
{initiative.title}
+
+
+
+ {seatLabel(accepted, initiative.maxMembers)}
+ {initiative.commitment ? ` · ${initiative.commitment}` : ""}
+
+
+ {/* Said once, at the top: every Accept below is off and the reason has to
+ be readable without hovering a disabled button. */}
+ {full && (
+
+ Every spot is taken. Raise the team size, or remove somebody, before
+ accepting anyone else.
+
+ )}
+
+ {state === "draft" && (
+
+ This is still a draft, so members cannot see it or apply. Open it from
+ your initiatives list.
+
+ )}
+
+ {applicants.length === 0 ? (
+
+ Nobody has applied yet.
+
+ {state === "open"
+ ? "It is open, so it is showing on the members' initiatives page."
+ : "Open it from your initiatives list and it will start showing to members."}
+
+
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/sites/mainweb/app/(portal)/lead/page.tsx b/sites/mainweb/app/(portal)/lead/page.tsx
new file mode 100644
index 00000000..692364fe
--- /dev/null
+++ b/sites/mainweb/app/(portal)/lead/page.tsx
@@ -0,0 +1,380 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { useSession } from "next-auth/react";
+import { Rocket } from "lucide-react";
+import { LiquidGlass } from "@/components/portal/LiquidGlass";
+import { LoadingScreen } from "@/components/portal/LoadingScreen";
+import {
+ InitiativeChip,
+ initiativeState,
+ seatLabel,
+} from "@/components/portal/initiatives/chips";
+import { trpc } from "@/lib/trpc";
+import type { RouterOutputs } from "@query/api";
+
+type LeadInitiative = RouterOutputs["initiative"]["listMine"][number];
+
+const statuses = [
+ { value: "draft", label: "Draft" },
+ { value: "open", label: "Open" },
+ { value: "closed", label: "Closed" },
+] as const;
+
+const field =
+ "w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none";
+
+function InitiativeForm({
+ initiative,
+ onDone,
+}: {
+ initiative?: LeadInitiative;
+ onDone: () => void;
+}) {
+ const utils = trpc.useUtils();
+ const [error, setError] = useState(null);
+ const [form, setForm] = useState({
+ title: initiative?.title ?? "",
+ summary: initiative?.summary ?? "",
+ description: initiative?.description ?? "",
+ commitment: initiative?.commitment ?? "",
+ maxMembers: initiative?.maxMembers?.toString() ?? "",
+ });
+
+ const done = async () => {
+ await utils.initiative.listMine.invalidate();
+ onDone();
+ };
+
+ const create = trpc.initiative.create.useMutation({
+ onSuccess: done,
+ onError: (e) => setError(e.message),
+ });
+ const update = trpc.initiative.update.useMutation({
+ onSuccess: done,
+ onError: (e) => setError(e.message),
+ });
+ const pending = create.isPending || update.isPending;
+
+ const set = (key: keyof typeof form) => (value: string) =>
+ setForm((prev) => ({ ...prev, [key]: value }));
+
+ return (
+
+ {
+ event.preventDefault();
+ setError(null);
+ const values = {
+ title: form.title.trim(),
+ summary: form.summary.trim() || undefined,
+ description: form.description.trim() || undefined,
+ commitment: form.commitment.trim() || undefined,
+ maxMembers: form.maxMembers ? Number(form.maxMembers) : null,
+ };
+ if (initiative) update.mutate({ ...values, id: initiative.id });
+ else create.mutate(values);
+ }}
+ >
+
+ {initiative ? "Edit initiative" : "New initiative"}
+
+
+
+
+ {error && {error}
}
+
+
+
+ {pending ? "Saving..." : initiative ? "Save changes" : "Create"}
+
+
+ Cancel
+
+
+
+ {!initiative && (
+
+ It starts as a draft. Nothing reaches members until you open it.
+
+ )}
+
+
+ );
+}
+
+function InitiativeRow({ initiative }: { initiative: LeadInitiative }) {
+ const utils = trpc.useUtils();
+ const [editing, setEditing] = useState(false);
+ const [confirmArchive, setConfirmArchive] = useState(false);
+
+ // The applicants screen reads getById, so invalidating only the list leaves
+ // it serving a stale initiative.
+ const refresh = async () => {
+ await Promise.all([
+ utils.initiative.listMine.invalidate(),
+ utils.initiative.getById.invalidate({ id: initiative.id }),
+ ]);
+ };
+
+ const setStatus = trpc.initiative.setStatus.useMutation({ onSuccess: refresh });
+ const archive = trpc.initiative.setArchived.useMutation({ onSuccess: refresh });
+
+ if (editing) {
+ return (
+ setEditing(false)} />
+ );
+ }
+
+ const state = initiativeState(initiative);
+ const archived = state === "archived";
+
+ return (
+
+
+
+
+
{initiative.title}
+
+
+ {initiative.summary && (
+
{initiative.summary}
+ )}
+
+ {seatLabel(initiative.accepted, initiative.maxMembers)}
+ {initiative.pending > 0
+ ? ` · ${initiative.pending} waiting on you`
+ : " · nobody waiting"}
+
+ {!initiative.isMine && (
+
+ Led by {initiative.leaderName}
+
+ )}
+
+
+
0
+ ? "shrink-0 rounded-full bg-white px-4 py-2 text-sm font-semibold text-black transition hover:bg-white/90"
+ : "shrink-0 rounded-full border border-white/15 px-4 py-2 text-sm font-semibold text-white/80 transition hover:bg-white/5"
+ }
+ >
+ {initiative.pending > 0
+ ? `Review ${initiative.pending}`
+ : "Applications"}
+
+
+
+
+
+ {statuses.map((option) => (
+
+ setStatus.mutate({ id: initiative.id, status: option.value })
+ }
+ className={`rounded-full px-3 py-1.5 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-40 ${
+ initiative.status === option.value
+ ? "bg-white text-black"
+ : "text-white/60 hover:text-white"
+ }`}
+ >
+ {option.label}
+
+ ))}
+
+
+
setEditing(true)}
+ className="rounded-full border border-white/15 px-4 py-2 text-sm font-semibold text-white/80 transition hover:bg-white/5"
+ >
+ Edit
+
+
+
+ {confirmArchive ? (
+ <>
+
+ {archived ? "Restore?" : "Archive?"}
+
+ {
+ archive.mutate({
+ id: initiative.id,
+ archived: !archived,
+ });
+ setConfirmArchive(false);
+ }}
+ className="rounded-full bg-red-500/20 px-3 py-1.5 text-sm font-semibold text-red-200 transition hover:bg-red-500/30"
+ >
+ Yes
+
+ setConfirmArchive(false)}
+ className="rounded-full border border-white/15 px-3 py-1.5 text-sm font-semibold text-white/80"
+ >
+ Cancel
+
+ >
+ ) : (
+ setConfirmArchive(true)}
+ className="text-sm font-semibold text-white/50 transition hover:text-white"
+ >
+ {archived ? "Restore" : "Archive"}
+
+ )}
+
+
+
+ {(setStatus.error ?? archive.error) && (
+
+ {(setStatus.error ?? archive.error)?.message}
+
+ )}
+
+ );
+}
+
+export default function LeadPage() {
+ const { data: session, status } = useSession();
+ const [creating, setCreating] = useState(false);
+
+ const listing = trpc.initiative.listMine.useQuery(undefined, {
+ enabled: !!session,
+ });
+
+ if (status === "loading" || listing.isPending) return ;
+
+ if (listing.error) {
+ return (
+
+
+
+ {listing.error.data?.code === "FORBIDDEN"
+ ? "You are not a project leader for this edition."
+ : listing.error.message}
+
+
+ Browse initiatives instead
+
+
+
+ );
+ }
+
+ const initiatives = listing.data ?? [];
+ const waiting = initiatives.reduce((total, row) => total + row.pending, 0);
+
+ return (
+
+
+
+ {creating && (
+
+ setCreating(false)} />
+
+ )}
+
+ {initiatives.length > 0 ? (
+
+ {initiatives.map((initiative) => (
+
+ ))}
+
+ ) : (
+
+ No initiatives yet.
+
+ A new one starts as a draft, so you can write it up now and open it
+ to members when you are ready.
+
+
+ )}
+
+ );
+}
diff --git a/sites/mainweb/components/portal/PortalSidebar.tsx b/sites/mainweb/components/portal/PortalSidebar.tsx
index d6d09163..8ec47656 100644
--- a/sites/mainweb/components/portal/PortalSidebar.tsx
+++ b/sites/mainweb/components/portal/PortalSidebar.tsx
@@ -21,6 +21,7 @@ import {
Home,
ShieldAlert,
UserCircle,
+ Rocket,
} from "lucide-react";
import { useTheme } from "next-themes";
import { usePortalContext } from "@/lib/use-portal-context";
@@ -83,6 +84,21 @@ export default function PortalSidebar({
icon: ClipboardList,
show: portalContext?.isJudge && !portalContext?.isAdmin,
},
+ {
+ name: "Initiatives",
+ href: "/initiatives",
+ icon: Rocket,
+ // Shown to any signed-in non-admin: somebody deciding whether to pay
+ // should be able to see what membership gets them. Applying is where the
+ // membership check bites, not browsing.
+ show: !portalContext?.isAdmin,
+ },
+ {
+ name: "My Initiatives",
+ href: "/lead",
+ icon: Rocket,
+ show: portalContext?.isProjectLeader && !portalContext?.isAdmin,
+ },
{
name: "Settings",
href: "/settings",
@@ -95,6 +111,7 @@ export default function PortalSidebar({
{ name: "Club Hub", href: "/admin", icon: LayoutDashboard },
{ name: "Hackathons", href: "/admin/hackathons", icon: Code },
{ name: "Judging", href: "/admin/judging", icon: ClipboardList },
+ { name: "Initiatives", href: "/admin/initiatives", icon: Rocket },
{ name: "Attendees", href: "/admin/attendees", icon: Users },
{ name: "Analytics", href: "/admin/analytics", icon: BarChart3 },
];
diff --git a/sites/mainweb/components/portal/StripePaymentModal.tsx b/sites/mainweb/components/portal/StripePaymentModal.tsx
index d0b529de..7b0b4cdd 100644
--- a/sites/mainweb/components/portal/StripePaymentModal.tsx
+++ b/sites/mainweb/components/portal/StripePaymentModal.tsx
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useCallback, useMemo } from "react";
+import { createPortal } from "react-dom";
import {
Elements,
PaymentElement,
@@ -254,17 +255,34 @@ export function StripePaymentModal({
colorIconCardError: "#ef4444",
},
rules: {
+ /**
+ * `color` is set explicitly on every input state.
+ *
+ * The rules below override the input background, which takes it out
+ * of whatever the chosen theme pairs it with — so relying on the
+ * theme's default text colour left typed card numbers dark-on-dark
+ * and unreadable once you clicked into a field.
+ */
".Input": {
border: isLight ? "1px solid #e4e4e7" : "1px solid #2e2e2e",
backgroundColor: isLight ? "#f9fafb" : "#0a0a0a",
+ color: isLight ? "#09090b" : "#ffffff",
boxShadow: "none",
},
".Input:focus": {
border: isLight ? "1px solid #007a7a" : "1px solid #00A8A8",
+ backgroundColor: isLight ? "#ffffff" : "#0a0a0a",
+ color: isLight ? "#09090b" : "#ffffff",
boxShadow: isLight
? "0 0 0 2px rgba(0,122,122,0.12)"
: "0 0 0 2px rgba(0,168,168,0.15)",
},
+ ".Input--invalid": {
+ color: isLight ? "#09090b" : "#ffffff",
+ },
+ ".Input::placeholder": {
+ color: isLight ? "#a1a1aa" : "#6b6b6b",
+ },
".Label": {
fontWeight: "600",
fontSize: "11px",
@@ -338,8 +356,22 @@ function ModalShell({
onClose: () => void;
amountCents: number;
}) {
- return (
-
+ /**
+ * Rendered into document.body rather than in place.
+ *
+ * The membership card this opens from is a `.card-printed` container with
+ * `overflow: hidden` and `.card-printed > * { z-index: 2 }`, so a modal
+ * mounted inside it is clipped to the card and trapped in that subtree's
+ * stacking order — the page behind stayed visible instead of being covered.
+ * A portal puts it at the top level where `fixed inset-0` means the whole
+ * viewport.
+ */
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => setMounted(true), []);
+ if (!mounted) return null;
+
+ return createPortal(
+
{/* Backdrop */}
{children}
-
+ ,
+ document.body,
);
}
diff --git a/sites/mainweb/components/portal/initiatives/chips.tsx b/sites/mainweb/components/portal/initiatives/chips.tsx
new file mode 100644
index 00000000..89866c83
--- /dev/null
+++ b/sites/mainweb/components/portal/initiatives/chips.tsx
@@ -0,0 +1,84 @@
+import type { ApplicationStatus, InitiativeStatus } from "@query/db";
+
+/**
+ * One definition of each state, shared by the member list, the leader's queue
+ * and the admin screen — defining them per screen is how "Accepted" ends up
+ * green in one place and grey in another.
+ */
+
+const base =
+ "inline-flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold";
+
+const applicationTones: Record<
+ ApplicationStatus,
+ { member: string; leader: string; className: string }
+> = {
+ pending: {
+ member: "Waiting on the leader",
+ leader: "Undecided",
+ className: "bg-white/10 text-white/70",
+ },
+ accepted: {
+ member: "On the team",
+ leader: "Accepted",
+ className: "bg-emerald-500/15 text-emerald-300",
+ },
+ rejected: {
+ member: "Not this time",
+ leader: "Rejected",
+ className: "bg-white/5 text-white/50",
+ },
+ withdrawn: {
+ member: "Withdrawn",
+ leader: "Withdrawn",
+ className: "bg-white/5 text-white/50",
+ },
+};
+
+export function ApplicationChip({
+ status,
+ side = "member",
+}: {
+ status: ApplicationStatus;
+ side?: "member" | "leader";
+}) {
+ const tone = applicationTones[status];
+ return (
+ {tone[side]}
+ );
+}
+
+/** Archived is a timestamp, not a status, but it outranks the other three. */
+export type InitiativeState = InitiativeStatus | "archived";
+
+const initiativeTones: Record<
+ InitiativeState,
+ { label: string; className: string }
+> = {
+ draft: { label: "Draft", className: "bg-white/10 text-white/60" },
+ open: {
+ label: "Taking applications",
+ className: "bg-emerald-500/15 text-emerald-300",
+ },
+ closed: { label: "Applications closed", className: "bg-white/10 text-white/60" },
+ archived: { label: "Archived", className: "bg-amber-500/15 text-amber-300" },
+};
+
+export function initiativeState(initiative: {
+ status: InitiativeStatus;
+ archivedAt: Date | string | null;
+}): InitiativeState {
+ return initiative.archivedAt !== null ? "archived" : initiative.status;
+}
+
+export function InitiativeChip({ state }: { state: InitiativeState }) {
+ const tone = initiativeTones[state];
+ return {tone.label} ;
+}
+
+/** A null cap is uncapped, not zero — "4 of 0" reads as nobody can join. */
+export function seatLabel(accepted: number, maxMembers: number | null) {
+ return maxMembers === null
+ ? `${accepted} on the team`
+ : `${accepted} of ${maxMembers} spots taken`;
+}
From 3329ad5fc61f176fde5e93a00531c2a6976dde61 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Wed, 5 Aug 2026 12:57:56 -0400
Subject: [PATCH 02/10] more
---
packages/api/src/routers/initiative.ts | 330 +++++++++++++++++-
packages/api/src/routers/member.ts | 104 +++---
.../src/routers/user/portal-context.test.ts | 14 +-
.../api/src/services/portal-context.test.ts | 32 +-
packages/api/src/services/portal-context.ts | 19 +-
packages/api/src/trpc.ts | 6 +-
packages/api/src/types/portal-context.ts | 10 +
packages/db/src/schemas/initiatives.ts | 31 +-
.../app/(portal)/admin/initiatives/page.tsx | 138 ++++++++
sites/mainweb/app/(portal)/dashboard/page.tsx | 43 ++-
.../mainweb/app/(portal)/initiatives/page.tsx | 183 +++++++++-
sites/mainweb/app/(portal)/lead/page.tsx | 70 +---
.../components/portal/initiatives/chips.tsx | 5 +
.../portal/initiatives/form-fields.tsx | 111 ++++++
14 files changed, 961 insertions(+), 135 deletions(-)
create mode 100644 sites/mainweb/components/portal/initiatives/form-fields.tsx
diff --git a/packages/api/src/routers/initiative.ts b/packages/api/src/routers/initiative.ts
index d1ea78d2..f44993de 100644
--- a/packages/api/src/routers/initiative.ts
+++ b/packages/api/src/routers/initiative.ts
@@ -41,15 +41,23 @@ const initiativeInput = z.object({
});
/**
- * Admins manage every initiative; a leader manages only their own. Callers
- * turn a false into NOT_FOUND rather than FORBIDDEN, so a leader who guesses
- * another leader's id does not learn from the error that it exists.
+ * Admins manage every initiative; a leader manages only their own, and only in
+ * the edition they currently lead. The role is granted per hackathon, so
+ * matching on leaderUserId alone would let this year's leader reach the
+ * initiative they ran last year — and its applicants' names, emails, and
+ * pitches — long after that appointment lapsed. Callers turn a false into
+ * NOT_FOUND rather than FORBIDDEN, so a leader who guesses another leader's id
+ * does not learn from the error that it exists.
*/
function canManage(
- ctx: { userId: string; isPlatformAdmin: boolean },
+ ctx: { userId: string; hackathonId: string; isPlatformAdmin: boolean },
initiative: Initiative,
) {
- return ctx.isPlatformAdmin || initiative.leaderUserId === ctx.userId;
+ return (
+ ctx.isPlatformAdmin ||
+ (initiative.hackathonId === ctx.hackathonId &&
+ initiative.leaderUserId === ctx.userId)
+ );
}
/** Applying is a member benefit, so it needs a membership that has not lapsed. */
@@ -128,6 +136,9 @@ export const initiativeRouter = createTRPCRouter({
.where(
and(
eq(initiatives.hackathonId, ctx.hackathonId),
+ // Proposals and declines live in the member's own list and the admin
+ // review queue; this screen is for initiatives that actually exist.
+ inArray(initiatives.status, ["draft", "open", "closed"]),
ctx.isPlatformAdmin
? undefined
: eq(initiatives.leaderUserId, ctx.userId),
@@ -210,13 +221,55 @@ export const initiativeRouter = createTRPCRouter({
}),
create: isProjectLeader
- .input(initiativeInput)
+ /**
+ * `leaderUserId` exists because an admin passes this gate without being a
+ * leader themselves. Defaulting it to the caller stored the ADMIN as the
+ * leader and showed their name to members, so staff creating an initiative
+ * on somebody's behalf name that person explicitly.
+ */
+ .input(initiativeInput.extend({ leaderUserId: z.string().optional() }))
.mutation(async ({ ctx, input }) => {
- const [created] = await (ctx.db as DrizzleDB)
+ const db = ctx.db as DrizzleDB;
+ let leaderUserId = ctx.userId;
+
+ if (input.leaderUserId && input.leaderUserId !== ctx.userId) {
+ if (!ctx.isPlatformAdmin) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Only an admin can create an initiative for someone else.",
+ });
+ }
+
+ const target = await db.query.projectLeaders.findFirst({
+ where: and(
+ eq(projectLeaders.userId, input.leaderUserId),
+ eq(projectLeaders.hackathonId, ctx.hackathonId),
+ eq(projectLeaders.isActive, true),
+ ),
+ columns: { id: true },
+ });
+ if (!target) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "That person is not a project leader for this edition.",
+ });
+ }
+ leaderUserId = input.leaderUserId;
+ } else if (!ctx.projectLeader) {
+ // An admin who named nobody would otherwise become the leader by
+ // default, which is the bug this whole branch exists to stop.
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "You are not a project leader. Name the leader this initiative belongs to.",
+ });
+ }
+
+ const [created] = await db
.insert(initiatives)
.values({
hackathonId: ctx.hackathonId,
- leaderUserId: ctx.userId,
+ leaderUserId,
title: input.title,
summary: input.summary ?? null,
description: input.description ?? null,
@@ -350,13 +403,17 @@ export const initiativeRouter = createTRPCRouter({
)
.mutation(async ({ ctx, input }) => {
return (ctx.db as DrizzleDB).transaction(async (tx) => {
+ // Lock BEFORE reading. Reading first and locking after leaves every
+ // check below running on a pre-lock snapshot, so a concurrent archive
+ // or a lowered cap is invisible and the accept goes through anyway.
+ // Locking an id that does not exist simply matches no row.
+ await lockInitiative(tx, input.initiativeId);
+
const initiative = await tx.query.initiatives.findFirst({
where: eq(initiatives.id, input.initiativeId),
});
if (!initiative || !canManage(ctx, initiative)) throw notFound();
- await lockInitiative(tx, initiative.id);
-
const application = await tx.query.initiativeApplications.findFirst({
where: and(
eq(initiativeApplications.initiativeId, initiative.id),
@@ -553,14 +610,26 @@ export const initiativeRouter = createTRPCRouter({
const pitch = input.pitch?.length ? input.pitch : null;
return db.transaction(async (tx) => {
+ // Lock BEFORE reading, so every guard below sees the row as it is now
+ // rather than as it was before the lock was granted — otherwise a
+ // leader closing the initiative, archiving it, or lowering the cap
+ // mid-flight is invisible here and the application lands anyway.
+ await lockInitiative(tx, input.initiativeId);
+
const initiative = await tx.query.initiatives.findFirst({
where: eq(initiatives.id, input.initiativeId),
});
if (!initiative) throw notFound();
- // A draft or archived initiative is invisible to members, so it answers
- // exactly the way a made-up id does.
- if (initiative.archivedAt !== null || initiative.status === "draft") {
+ // Anything not open is invisible to members, so it answers exactly the
+ // way a made-up id does — including `proposed` and `declined`, which
+ // would otherwise leak that somebody pitched this idea.
+ if (
+ initiative.archivedAt !== null ||
+ initiative.status === "draft" ||
+ initiative.status === "proposed" ||
+ initiative.status === "declined"
+ ) {
throw notFound();
}
@@ -580,8 +649,6 @@ export const initiativeRouter = createTRPCRouter({
});
}
- await lockInitiative(tx, initiative.id);
-
const existing = await tx.query.initiativeApplications.findFirst({
where: and(
eq(initiativeApplications.initiativeId, initiative.id),
@@ -671,8 +738,241 @@ export const initiativeRouter = createTRPCRouter({
return { withdrawn: updated !== undefined };
}),
+ // --------------------------------------------------------------- proposals
+
+ /**
+ * A member asking to run something. Creates the initiative at `proposed`,
+ * with the proposer as its leader — the row is the proposal, so approving it
+ * is a status change rather than a copy from a second table that could drift.
+ */
+ propose: protectedProcedure
+ .input(initiativeInput)
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+ const hackathonId = await resolveHackathonId(db);
+ if (!hackathonId) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "No hackathon context found",
+ });
+ }
+
+ await requireActiveMember(db, ctx.userId, hackathonId);
+
+ // A queue an admin has to read is a shared resource. Three open at once
+ // is plenty for one person and stops a single member flooding it.
+ const [waiting] = await db
+ .select({ total: count() })
+ .from(initiatives)
+ .where(
+ and(
+ eq(initiatives.hackathonId, hackathonId),
+ eq(initiatives.leaderUserId, ctx.userId),
+ eq(initiatives.status, "proposed"),
+ ),
+ );
+
+ if ((waiting?.total ?? 0) >= 3) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "You already have three proposals waiting. Wait for one to be reviewed, or withdraw it.",
+ });
+ }
+
+ const [created] = await db
+ .insert(initiatives)
+ .values({
+ hackathonId,
+ leaderUserId: ctx.userId,
+ title: input.title,
+ summary: input.summary ?? null,
+ description: input.description ?? null,
+ commitment: input.commitment ?? null,
+ maxMembers: input.maxMembers ?? null,
+ status: "proposed",
+ })
+ .returning();
+
+ if (!created) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Could not submit that proposal.",
+ });
+ }
+ return created;
+ }),
+
+ /**
+ * Everything this member has proposed, in any state. Separate from
+ * `listMine` because a member with a pending proposal is not a leader yet
+ * and cannot pass that gate.
+ */
+ myProposals: protectedProcedure.query(async ({ ctx }) => {
+ const db = ctx.db as DrizzleDB;
+ const hackathonId = await resolveHackathonId(db);
+ if (!hackathonId) return [];
+
+ return db
+ .select({
+ id: initiatives.id,
+ title: initiatives.title,
+ summary: initiatives.summary,
+ description: initiatives.description,
+ commitment: initiatives.commitment,
+ status: initiatives.status,
+ maxMembers: initiatives.maxMembers,
+ archivedAt: initiatives.archivedAt,
+ reviewedAt: initiatives.reviewedAt,
+ reviewNote: initiatives.reviewNote,
+ createdAt: initiatives.createdAt,
+ })
+ .from(initiatives)
+ .where(
+ and(
+ eq(initiatives.hackathonId, hackathonId),
+ eq(initiatives.leaderUserId, ctx.userId),
+ ),
+ )
+ .orderBy(desc(initiatives.createdAt))
+ .limit(40);
+ }),
+
+ /** Taking a proposal back before anyone has reviewed it. */
+ withdrawProposal: protectedProcedure
+ .input(z.object({ id: z.string().uuid() }))
+ .mutation(async ({ ctx, input }) => {
+ const deleted = await (ctx.db as DrizzleDB)
+ .delete(initiatives)
+ .where(
+ and(
+ eq(initiatives.id, input.id),
+ eq(initiatives.leaderUserId, ctx.userId),
+ // Only while it is still untouched. Once it is approved it is a
+ // real initiative with applicants, and archiving is the way out.
+ eq(initiatives.status, "proposed"),
+ ),
+ )
+ .returning({ id: initiatives.id });
+
+ if (deleted.length === 0) {
+ throw notFound("That proposal is no longer pending.");
+ }
+ return { withdrawn: true };
+ }),
+
// ------------------------------------------------------------------- admin
+ /** The review queue. Oldest first — proposals are answered in order. */
+ listProposals: isAdmin.query(async ({ ctx }) => {
+ const db = ctx.db as DrizzleDB;
+ const hackathonId = await resolveHackathonId(db);
+ if (!hackathonId) return [];
+
+ return db
+ .select({
+ id: initiatives.id,
+ title: initiatives.title,
+ summary: initiatives.summary,
+ description: initiatives.description,
+ commitment: initiatives.commitment,
+ maxMembers: initiatives.maxMembers,
+ status: initiatives.status,
+ createdAt: initiatives.createdAt,
+ proposerId: initiatives.leaderUserId,
+ proposerName: users.name,
+ proposerEmail: users.email,
+ })
+ .from(initiatives)
+ .innerJoin(users, eq(users.id, initiatives.leaderUserId))
+ .where(
+ and(
+ eq(initiatives.hackathonId, hackathonId),
+ eq(initiatives.status, "proposed"),
+ ),
+ )
+ .orderBy(asc(initiatives.createdAt))
+ .limit(100);
+ }),
+
+ /**
+ * Approving does two things at once, so they share a transaction: the
+ * initiative becomes a draft and the proposer becomes a project leader. Doing
+ * only the first would leave somebody owning an initiative they cannot reach.
+ */
+ reviewProposal: isAdmin
+ .input(
+ z.object({
+ id: z.string().uuid(),
+ decision: z.enum(["approve", "decline"]),
+ note: z.string().trim().max(1000).optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const proposerId = await db.transaction(async (tx) => {
+ const proposal = await tx.query.initiatives.findFirst({
+ where: eq(initiatives.id, input.id),
+ });
+ if (!proposal) throw notFound("Proposal not found.");
+
+ if (proposal.status !== "proposed") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "That proposal has already been reviewed.",
+ });
+ }
+
+ await tx
+ .update(initiatives)
+ .set({
+ status: input.decision === "approve" ? "draft" : "declined",
+ reviewedAt: new Date(),
+ reviewedById: ctx.userId,
+ reviewNote: input.note ?? null,
+ updatedAt: new Date(),
+ })
+ .where(eq(initiatives.id, proposal.id));
+
+ if (input.decision === "approve") {
+ const existing = await tx.query.projectLeaders.findFirst({
+ where: and(
+ eq(projectLeaders.userId, proposal.leaderUserId),
+ eq(projectLeaders.hackathonId, proposal.hackathonId),
+ ),
+ });
+
+ if (existing) {
+ // Re-approving somebody whose role was revoked restores it rather
+ // than colliding with the unique index.
+ if (!existing.isActive) {
+ await tx
+ .update(projectLeaders)
+ .set({ isActive: true, updatedAt: new Date() })
+ .where(eq(projectLeaders.id, existing.id));
+ }
+ } else {
+ await tx.insert(projectLeaders).values({
+ userId: proposal.leaderUserId,
+ hackathonId: proposal.hackathonId,
+ isActive: true,
+ appointedBy: ctx.userId,
+ });
+ }
+ }
+
+ return proposal.leaderUserId;
+ });
+
+ // Outside the transaction: the role gate and the sidebar both cache, and
+ // evicting before commit would let a concurrent read re-warm the old
+ // answer. Approval is the moment a member gains a whole new tab.
+ if (input.decision === "approve") clearProjectLeaderCaches(proposerId);
+
+ return { id: input.id, decision: input.decision };
+ }),
+
listLeaders: isAdmin.query(async ({ ctx }) => {
const db = ctx.db as DrizzleDB;
const hackathonId = await resolveHackathonId(db);
diff --git a/packages/api/src/routers/member.ts b/packages/api/src/routers/member.ts
index 7342bf05..07d18bc7 100644
--- a/packages/api/src/routers/member.ts
+++ b/packages/api/src/routers/member.ts
@@ -1,7 +1,9 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
-import { members, membershipHistory } from "@query/db";
+// membershipHistory is written by createOrUpdateMembership on a real payment,
+// not here: `register` no longer grants a term, so it has nothing to record.
+import { members } from "@query/db";
import { eq, and } from "drizzle-orm";
import type { DrizzleDB } from "@query/db";
import { invalidatePortalContext } from "../middleware/cache";
@@ -85,51 +87,55 @@ export const memberRouter = createTRPCRouter({
});
}
- const membershipStartDate = new Date();
- const membershipEndDate = new Date();
- membershipEndDate.setFullYear(membershipEndDate.getFullYear() + 1);
-
- const newMember = await (ctx.db as DrizzleDB).transaction(async (tx) => {
- const result = await tx
- .insert(members)
- .values({
- userId: ctx.userId!,
- hackathonId,
- memberType: "new",
- firstName: input.firstName,
- lastName: input.lastName,
- phoneNumber: input.phoneNumber,
- school: input.school,
- major: input.major,
- graduationYear: input.graduationYear,
- skills: input.skills || [],
- interests: input.interests || [],
- linkedinUrl: input.linkedinUrl,
- githubUrl: input.githubUrl,
- portfolioUrl: input.portfolioUrl,
- membershipStartDate,
- membershipEndDate,
- })
- .returning();
-
- const created = result[0];
-
- if (!created) {
- throw new TRPCError({
- code: "INTERNAL_SERVER_ERROR",
- message: "Failed to create member",
- });
- }
-
- await tx.insert(membershipHistory).values({
- memberId: created.id,
- action: "joined",
- startDate: membershipStartDate,
- endDate: membershipEndDate,
+ /**
+ * This writes a PROFILE, not a membership.
+ *
+ * It used to stamp `membershipEndDate = now + 1 year` and let the column
+ * default `isActive` to true, which handed any signed-in caller a full
+ * paid-tier membership over tRPC for nothing — the same hole the comment
+ * below records for the deleted `renew` endpoint. A membership is one
+ * paid year and `createOrUpdateMembership`, driven by a completed
+ * payment, is the only thing that may set a term.
+ *
+ * `membershipStartDate` is not null in the schema, so it carries when the
+ * profile was created. It grants nothing on its own: `isActive` is false
+ * and `membershipEndDate` is null, and both `checkStatus` and
+ * `buildMemberContext` require an unexpired end date.
+ */
+ const result = await (ctx.db as DrizzleDB)
+ .insert(members)
+ .values({
+ userId: ctx.userId!,
+ hackathonId,
+ memberType: "new",
+ firstName: input.firstName,
+ lastName: input.lastName,
+ phoneNumber: input.phoneNumber,
+ school: input.school,
+ major: input.major,
+ graduationYear: input.graduationYear,
+ skills: input.skills || [],
+ interests: input.interests || [],
+ linkedinUrl: input.linkedinUrl,
+ githubUrl: input.githubUrl,
+ portfolioUrl: input.portfolioUrl,
+ membershipStartDate: new Date(),
+ membershipEndDate: null,
+ isActive: false,
+ })
+ .returning();
+
+ const newMember = result[0];
+
+ if (!newMember) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to create member",
});
+ }
- return created;
- });
+ // No membershipHistory "joined" row either: nothing was joined until a
+ // payment lands, and createOrUpdateMembership is what records that.
invalidatePortalContext(ctx.userId!);
@@ -339,6 +345,7 @@ export const memberRouter = createTRPCRouter({
return {
isMember: false,
isActive: false,
+ hasLapsed: false,
expiresAt: null,
daysRemaining: null,
memberType: null,
@@ -350,6 +357,7 @@ export const memberRouter = createTRPCRouter({
const cached = ctx.cache.get<{
isMember: boolean;
isActive: boolean;
+ hasLapsed: boolean;
expiresAt: Date | null;
daysRemaining: number | null;
memberType: string | null;
@@ -368,6 +376,7 @@ export const memberRouter = createTRPCRouter({
const result = {
isMember: false,
isActive: false,
+ hasLapsed: false,
expiresAt: null,
daysRemaining: null,
memberType: null,
@@ -389,8 +398,13 @@ export const memberRouter = createTRPCRouter({
}
const result = {
- isMember: true,
+ // Paid and unexpired. A profile row with no payment, and a row whose
+ // year has run out, both answer false — the same rule the portal
+ // context uses, so the two can never disagree.
+ isMember: isActive,
isActive,
+ // Same rule as buildMemberContext: ran out, not revoked.
+ hasLapsed: !isActive && Boolean(expiresAt) && expiresAt! <= now,
memberType: member.memberType,
expiresAt,
daysRemaining,
diff --git a/packages/api/src/routers/user/portal-context.test.ts b/packages/api/src/routers/user/portal-context.test.ts
index 27a79c1c..0209d2cc 100644
--- a/packages/api/src/routers/user/portal-context.test.ts
+++ b/packages/api/src/routers/user/portal-context.test.ts
@@ -12,12 +12,20 @@ vi.mock("@query/db", () => ({
hackathons: { findFirst: (...args: unknown[]) => mockFindFirst("hackathons", ...args) },
judges: { findFirst: (...args: unknown[]) => mockFindFirst("judges", ...args) },
members: { findFirst: (...args: unknown[]) => mockFindFirst("members", ...args) },
+ projectLeaders: {
+ findFirst: (...args: unknown[]) => mockFindFirst("projectLeaders", ...args),
+ },
users: { findFirst: vi.fn() },
},
},
admins: { userId: "user_id", isActive: "is_active" },
members: { userId: "user_id", hackathonId: "hackathon_id" },
judges: { userId: "user_id", isActive: "is_active" },
+ projectLeaders: {
+ userId: "user_id",
+ hackathonId: "hackathon_id",
+ isActive: "is_active",
+ },
hackathons: { startDate: "start_date" },
users: { id: "id" },
userProfiles: { userId: "user_id" },
@@ -56,12 +64,16 @@ describe("user.getPortalContext", () => {
const caller = appRouter.createCaller(ctx);
const first = await caller.user.getPortalContext();
+ const afterFirst = mockFindFirst.mock.calls.length;
const second = await caller.user.getPortalContext();
expect(first.isAdmin).toBe(true);
expect(first.isJudge).toBe(false);
expect(first.member.isMember).toBe(true);
expect(second).toEqual(first);
- expect(mockFindFirst).toHaveBeenCalledTimes(4);
+ // The point of the assertion is the cache, not the exact fan-out: the
+ // second call must reach the database zero times.
+ expect(afterFirst).toBeGreaterThan(0);
+ expect(mockFindFirst).toHaveBeenCalledTimes(afterFirst);
});
});
diff --git a/packages/api/src/services/portal-context.test.ts b/packages/api/src/services/portal-context.test.ts
index d608bfd8..bc639e72 100644
--- a/packages/api/src/services/portal-context.test.ts
+++ b/packages/api/src/services/portal-context.test.ts
@@ -5,6 +5,11 @@ vi.mock("@query/db", () => ({
admins: { userId: "user_id", isActive: "is_active" },
members: { userId: "user_id", hackathonId: "hackathon_id" },
judges: { userId: "user_id", isActive: "is_active" },
+ projectLeaders: {
+ userId: "user_id",
+ hackathonId: "hackathon_id",
+ isActive: "is_active",
+ },
hackathons: { startDate: "start_date" },
}));
@@ -34,7 +39,7 @@ describe("buildMemberContext", () => {
expect(buildMemberContext(undefined)).toEqual(EMPTY_MEMBER_CONTEXT);
});
- it("marks expired memberships inactive", () => {
+ it("marks expired memberships lapsed, not current", () => {
const past = new Date("2020-01-01");
const ctx = buildMemberContext({
isActive: true,
@@ -42,11 +47,28 @@ describe("buildMemberContext", () => {
memberType: "continuous",
renewalCount: 1,
});
- expect(ctx.isMember).toBe(true);
+ // A row that outlived the year it paid for is not a membership: reporting
+ // it as one is what greeted a lapsed member as active and hid the only
+ // renew button behind the same flag.
+ expect(ctx.isMember).toBe(false);
expect(ctx.isActive).toBe(false);
+ expect(ctx.hasLapsed).toBe(true);
expect(ctx.daysRemaining).toBeLessThan(0);
});
+ it("does not call a revoked but unexpired membership lapsed", () => {
+ const future = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
+ const ctx = buildMemberContext({
+ isActive: false,
+ membershipEndDate: future,
+ memberType: "continuous",
+ renewalCount: 1,
+ });
+ expect(ctx.isActive).toBe(false);
+ // Switched off by staff, term still running — renewing is not the fix.
+ expect(ctx.hasLapsed).toBe(false);
+ });
+
it("marks active memberships with days remaining", () => {
const future = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000);
const ctx = buildMemberContext({
@@ -86,6 +108,9 @@ describe("fetchPortalContext", () => {
renewalCount: 2,
}),
},
+ projectLeaders: {
+ findFirst: async () => ({ id: "leader-1" }),
+ },
},
};
@@ -96,6 +121,7 @@ describe("fetchPortalContext", () => {
expect(result.permissions).toEqual(["events"]);
expect(result.isJudge).toBe(true);
expect(result.judgeId).toBe("judge-1");
+ expect(result.isProjectLeader).toBe(true);
expect(result.member.isMember).toBe(true);
expect(result.member.isActive).toBe(true);
});
@@ -107,6 +133,7 @@ describe("fetchPortalContext", () => {
hackathons: { findFirst: async () => null },
judges: { findFirst: async () => null },
members: { findFirst: async () => null },
+ projectLeaders: { findFirst: async () => null },
},
};
@@ -114,6 +141,7 @@ describe("fetchPortalContext", () => {
expect(result.isAdmin).toBe(false);
expect(result.isJudge).toBe(false);
+ expect(result.isProjectLeader).toBe(false);
expect(result.member).toEqual(EMPTY_MEMBER_CONTEXT);
});
});
diff --git a/packages/api/src/services/portal-context.ts b/packages/api/src/services/portal-context.ts
index 542d98ed..7434a22a 100644
--- a/packages/api/src/services/portal-context.ts
+++ b/packages/api/src/services/portal-context.ts
@@ -43,8 +43,25 @@ function buildMemberContext(
}
return {
- isMember: true,
+ /**
+ * Paid and unexpired, not merely "a row exists". A `member` row is also
+ * written for a profile with no payment behind it, and the row outlives the
+ * year it paid for — reporting either as a member is what let a lapsed
+ * member be greeted as active while the pay button stayed hidden.
+ *
+ * Club benefits gate on this. Hackathon participation deliberately does
+ * NOT: the hackathon is open to everyone, member or not.
+ */
+ isMember: isActive,
isActive,
+ /**
+ * Paid once, ran out — what turns the club view into a renew prompt.
+ *
+ * Ran out, rather than revoked: a row switched off while its date is still
+ * in the future is a staff action, and prompting that person to renew a
+ * membership they still hold would be wrong.
+ */
+ hasLapsed: !isActive && !!expiresAt && expiresAt <= now,
expiresAt,
daysRemaining,
memberType: memberRecord.memberType,
diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts
index 92e4f059..bd02daa2 100644
--- a/packages/api/src/trpc.ts
+++ b/packages/api/src/trpc.ts
@@ -291,8 +291,12 @@ const CACHE_INVALIDATION_MAP: Record = {
"initiative.decide": ["initiative:*"],
"initiative.requestToJoin": ["initiative:*"],
"initiative.withdraw": ["initiative:*"],
- // setLeader clears the role gate and portal context itself, by user id.
+ "initiative.propose": ["initiative:*"],
+ "initiative.withdrawProposal": ["initiative:*"],
+ // setLeader and reviewProposal clear the role gate and portal context
+ // themselves, by user id — this only sweeps the list caches.
"initiative.setLeader": ["initiative:*"],
+ "initiative.reviewProposal": ["initiative:*"],
// Events (club check-ins)
"events.create": ["events:list"],
"events.delete": ["events:list"],
diff --git a/packages/api/src/types/portal-context.ts b/packages/api/src/types/portal-context.ts
index 752f71bf..e40d5541 100644
--- a/packages/api/src/types/portal-context.ts
+++ b/packages/api/src/types/portal-context.ts
@@ -1,6 +1,15 @@
export type MemberContext = {
+ /**
+ * Membership is a paid year, so this is true only while one is paid for and
+ * unexpired. It used to be true for any `member` row at all, which meant a
+ * lapsed member still read as a member: the portal called them "Active
+ * Member", let them into /club, and hid the only payment button behind the
+ * same flag — leaving them no way to renew.
+ */
isMember: boolean;
isActive: boolean | null;
+ /** Had a membership, and it ran out. Drives the renew prompt. */
+ hasLapsed: boolean;
expiresAt: Date | null;
daysRemaining: number | null;
memberType: string | null;
@@ -22,6 +31,7 @@ export type PortalContext = {
export const EMPTY_MEMBER_CONTEXT: MemberContext = {
isMember: false,
isActive: false,
+ hasLapsed: false,
expiresAt: null,
daysRemaining: null,
memberType: null,
diff --git a/packages/db/src/schemas/initiatives.ts b/packages/db/src/schemas/initiatives.ts
index f7c1ddd4..5ec1b3d1 100644
--- a/packages/db/src/schemas/initiatives.ts
+++ b/packages/db/src/schemas/initiatives.ts
@@ -55,10 +55,30 @@ export const projectLeaders = pgTable(
export type ProjectLeader = typeof projectLeaders.$inferSelect;
-/** draft is invisible to members, open takes applications, closed stops them. */
-export const initiativeStatuses = ["draft", "open", "closed"] as const;
+/**
+ * The whole lifecycle, including the one a member starts.
+ *
+ * A member with no leader role proposes an initiative; it sits at `proposed`
+ * until an admin reviews it. Approving moves it to `draft` and grants the
+ * proposer the leader role, so they finish writing it and open it themselves —
+ * approval never publishes a half-written page to members. Declining parks it
+ * at `declined` with a note the proposer can read.
+ *
+ * Only `open` is ever visible to members. An existing leader skips the first
+ * two states entirely and creates straight into `draft`.
+ */
+export const initiativeStatuses = [
+ "proposed",
+ "declined",
+ "draft",
+ "open",
+ "closed",
+] as const;
export type InitiativeStatus = (typeof initiativeStatuses)[number];
+/** What a leader may set directly — the review states are not theirs to pick. */
+export const leaderSettableStatuses = ["draft", "open", "closed"] as const;
+
/**
* No accepted-seat counter here on purpose: every writer takes a row lock on
* the initiative first, so the accepted rows are counted directly and there is
@@ -87,6 +107,13 @@ export const initiatives = pgTable(
/** Null means uncapped. Zero would be an initiative nobody can join. */
maxMembers: integer("max_members"),
archivedAt: timestamp("archived_at"),
+ /** Set when an admin approves or declines a proposal. */
+ reviewedAt: timestamp("reviewed_at"),
+ reviewedById: text("reviewed_by_id").references(() => users.id, {
+ onDelete: "set null",
+ }),
+ /** The admin's note back to the proposer, shown on a decline. */
+ reviewNote: text("review_note"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
diff --git a/sites/mainweb/app/(portal)/admin/initiatives/page.tsx b/sites/mainweb/app/(portal)/admin/initiatives/page.tsx
index 9a094a47..4e9fc004 100644
--- a/sites/mainweb/app/(portal)/admin/initiatives/page.tsx
+++ b/sites/mainweb/app/(portal)/admin/initiatives/page.tsx
@@ -6,6 +6,7 @@ import { Rocket } from "lucide-react";
import { LiquidGlass } from "@/components/portal/LiquidGlass";
import { LoadingScreen } from "@/components/portal/LoadingScreen";
import { trpc } from "@/lib/trpc";
+import type { RouterOutputs } from "@query/api";
/**
* Who runs initiatives this edition.
@@ -14,6 +15,115 @@ import { trpc } from "@/lib/trpc";
* attendees list every officer already works from, and a leader has to have
* signed in at least once to have an id at all.
*/
+function ProposalRow({
+ proposal,
+}: {
+ proposal: RouterOutputs["initiative"]["listProposals"][number];
+}) {
+ const utils = trpc.useUtils();
+ const [note, setNote] = useState("");
+ const [declining, setDeclining] = useState(false);
+
+ const review = trpc.initiative.reviewProposal.useMutation({
+ onSuccess: async () => {
+ await Promise.all([
+ utils.initiative.listProposals.invalidate(),
+ // Approving mints a project leader, so that list moves too.
+ utils.initiative.listLeaders.invalidate(),
+ ]);
+ },
+ });
+
+ return (
+
+
+
+
{proposal.title}
+
+ {proposal.proposerName ?? proposal.proposerEmail} ·{" "}
+ {proposal.proposerEmail}
+
+ {proposal.summary && (
+
{proposal.summary}
+ )}
+ {proposal.description && (
+
+ {proposal.description}
+
+ )}
+
+ {proposal.commitment ?? "No commitment given"} ·{" "}
+ {proposal.maxMembers === null
+ ? "no team cap"
+ : `cap ${proposal.maxMembers}`}
+
+
+
+
+
+ review.mutate({ id: proposal.id, decision: "approve" })
+ }
+ className="rounded-full bg-white px-4 py-2 text-sm font-semibold text-black transition hover:bg-white/90 disabled:opacity-50"
+ >
+ Approve
+
+ setDeclining((prev) => !prev)}
+ className="rounded-full border border-white/15 px-4 py-2 text-sm font-semibold text-white/80 transition hover:bg-white/5 disabled:opacity-50"
+ >
+ Decline
+
+
+
+
+ {/* A decline without a reason is the thing a member can do nothing with,
+ so the note is asked for at the moment of declining. */}
+ {declining && (
+
+
+ Why (shown to them)
+
+ setNote(event.target.value)}
+ placeholder="Too close to an existing initiative, needs a clearer scope, ..."
+ className="mt-2 w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none"
+ />
+
+ review.mutate({
+ id: proposal.id,
+ decision: "decline",
+ note: note.trim() || undefined,
+ })
+ }
+ className="mt-3 rounded-full bg-red-500/20 px-4 py-2 text-sm font-semibold text-red-200 transition hover:bg-red-500/30 disabled:opacity-50"
+ >
+ Confirm decline
+
+
+ )}
+
+ {review.error && (
+ {review.error.message}
+ )}
+
+ );
+}
+
export default function AdminInitiativesPage() {
const { data: session, status } = useSession();
const utils = trpc.useUtils();
@@ -22,6 +132,9 @@ export default function AdminInitiativesPage() {
const leaders = trpc.initiative.listLeaders.useQuery(undefined, {
enabled: !!session,
});
+ const proposals = trpc.initiative.listProposals.useQuery(undefined, {
+ enabled: !!session,
+ });
const setLeader = trpc.initiative.setLeader.useMutation({
onSuccess: async () => {
@@ -57,6 +170,31 @@ export default function AdminInitiativesPage() {
+
+
+ Proposals waiting on you
+ {proposals.data && proposals.data.length > 0
+ ? ` · ${proposals.data.length}`
+ : ""}
+
+ {proposals.error ? (
+ {proposals.error.message}
+ ) : (proposals.data ?? []).length > 0 ? (
+
+ {(proposals.data ?? []).map((proposal) => (
+
+ ))}
+
+ ) : (
+
+
+ Nothing waiting. Members pitch initiatives from their Initiatives
+ page; approving one makes them a project leader.
+
+
+ )}
+
+
+ {/* ── CLUB / HACKATHON ───────────────────────────── */}
+ {/* Two different things this org does, and they have different rules:
+ the hackathon is open to anyone with an account, the club is the
+ paid yearly membership. Splitting them is what stops the dashboard
+ reading as though everything is behind the same paywall. */}
+
+ {(
+ [
+ { value: "hackathon", label: "Hackathon" },
+ { value: "club", label: "Club" },
+ ] as const
+ ).map((option) => (
+ setView(option.value)}
+ className={`rounded-sm px-5 py-2 text-sm font-bold uppercase tracking-wider transition-colors ${
+ view === option.value
+ ? "bg-accent/15 text-accent"
+ : "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
+ }`}
+ >
+ {option.label}
+
+ ))}
+
+
{/* ── ROLE TILES ─────────────────────────────────── */}
- {/* Hackathons — always visible */}
+ {/* Hackathons — open to everyone, no membership needed */}
+ {view === "hackathon" && (
@@ -182,14 +216,17 @@ export default function Dashboard() {
Hackathon Hub
- Browse and register for upcoming hackathons.
+ Browse and register for upcoming hackathons. Open to
+ everyone — no membership needed.
+ )}
{/* Club Portal — members only */}
- {memberStatus?.isMember ? (
+ {view === "club" &&
+ (memberStatus?.isMember ? (
diff --git a/sites/mainweb/app/(portal)/initiatives/page.tsx b/sites/mainweb/app/(portal)/initiatives/page.tsx
index 4e37354f..52ed6ab4 100644
--- a/sites/mainweb/app/(portal)/initiatives/page.tsx
+++ b/sites/mainweb/app/(portal)/initiatives/page.tsx
@@ -8,13 +8,166 @@ import { LiquidGlass } from "@/components/portal/LiquidGlass";
import { LoadingScreen } from "@/components/portal/LoadingScreen";
import {
ApplicationChip,
+ InitiativeChip,
+ initiativeState,
seatLabel,
} from "@/components/portal/initiatives/chips";
+import {
+ InitiativeFields,
+ emptyDraft,
+ toInput,
+} from "@/components/portal/initiatives/form-fields";
import { trpc } from "@/lib/trpc";
import type { RouterOutputs } from "@query/api";
type OpenInitiative = RouterOutputs["initiative"]["list"][number];
type MyApplication = RouterOutputs["initiative"]["myApplications"][number];
+type MyProposal = RouterOutputs["initiative"]["myProposals"][number];
+
+/**
+ * Proposing something to run, rather than joining something that exists.
+ *
+ * An admin reviews it; approving turns the proposal into a draft initiative
+ * and makes the proposer a project leader, so this is the one place a member
+ * can earn that role.
+ */
+function ProposeSection({ canPropose }: { canPropose: boolean }) {
+ const utils = trpc.useUtils();
+ const [open, setOpen] = useState(false);
+ const [draft, setDraft] = useState(emptyDraft);
+
+ const propose = trpc.initiative.propose.useMutation({
+ onSuccess: async () => {
+ setOpen(false);
+ setDraft(emptyDraft);
+ await utils.initiative.myProposals.invalidate();
+ },
+ });
+
+ if (!canPropose) return null;
+
+ if (!open) {
+ return (
+
+
+
Got something to build?
+
+ Pitch it. If it is approved you become its project leader and pick
+ who joins.
+
+
+ setOpen(true)}
+ className="shrink-0 rounded-full border border-white/15 px-4 py-2 text-sm font-semibold text-white/80 transition hover:bg-white/5"
+ >
+ Propose an initiative
+
+
+ );
+ }
+
+ return (
+
+ {
+ event.preventDefault();
+ propose.mutate(toInput(draft));
+ }}
+ >
+ Propose an initiative
+
+
+
+
+ {propose.isPending ? "Sending..." : "Send for review"}
+
+ {
+ setOpen(false);
+ propose.reset();
+ }}
+ className="rounded-full border border-white/15 px-4 py-2 text-sm font-semibold text-white/80 transition hover:bg-white/5"
+ >
+ Cancel
+
+
+
+ {propose.error && (
+
+ {propose.error.message}
+
+ )}
+
+
+ );
+}
+
+function ProposalRow({ proposal }: { proposal: MyProposal }) {
+ const utils = trpc.useUtils();
+
+ const withdraw = trpc.initiative.withdrawProposal.useMutation({
+ onSuccess: async () => {
+ await utils.initiative.myProposals.invalidate();
+ },
+ });
+
+ const state = initiativeState(proposal);
+
+ return (
+
+
+
+
+
{proposal.title}
+
+
+ {proposal.summary && (
+
{proposal.summary}
+ )}
+
+ {/* The reviewer's note is the whole point of a decline — without it a
+ member has no idea what to change before pitching again. */}
+ {proposal.reviewNote && (
+
+ {proposal.reviewNote}
+
+ )}
+
+ {proposal.status === "draft" && (
+
+ Approved — finish writing it and open it from{" "}
+
+ My Initiatives
+
+ .
+
+ )}
+
+
+ {proposal.status === "proposed" && (
+
withdraw.mutate({ id: proposal.id })}
+ className="shrink-0 text-sm font-semibold text-white/50 transition hover:text-white disabled:opacity-50"
+ >
+ Withdraw
+
+ )}
+
+
+ {withdraw.error && (
+ {withdraw.error.message}
+ )}
+
+ );
+}
function OpenRow({
initiative,
@@ -246,12 +399,21 @@ export default function InitiativesPage() {
const memberStatus = trpc.member.checkStatus.useQuery(undefined, {
enabled: !!session,
});
+ const proposals = trpc.initiative.myProposals.useQuery(undefined, {
+ enabled: !!session,
+ });
- if (status === "loading" || open.isPending || mine.isPending) {
+ if (
+ status === "loading" ||
+ open.isPending ||
+ mine.isPending ||
+ proposals.isPending
+ ) {
return
;
}
const applications = mine.data ?? [];
+ const myProposals = proposals.data ?? [];
// Already applied belongs in the member's own list, not in the one offering
// them a chance to apply again.
const joinable = (open.data ?? []).filter((row) => row.myStatus === null);
@@ -270,12 +432,27 @@ export default function InitiativesPage() {
- {(open.error ?? mine.error) && (
+ {(open.error ?? mine.error ?? proposals.error) && (
- {(open.error ?? mine.error)?.message}
+ {(open.error ?? mine.error ?? proposals.error)?.message}
)}
+
+
+ {myProposals.length > 0 && (
+
+
+ What you proposed
+
+
+ {myProposals.map((proposal) => (
+
+ ))}
+
+
+ )}
+
{applications.length > 0 && (
diff --git a/sites/mainweb/app/(portal)/lead/page.tsx b/sites/mainweb/app/(portal)/lead/page.tsx
index 692364fe..1481a5eb 100644
--- a/sites/mainweb/app/(portal)/lead/page.tsx
+++ b/sites/mainweb/app/(portal)/lead/page.tsx
@@ -11,6 +11,11 @@ import {
initiativeState,
seatLabel,
} from "@/components/portal/initiatives/chips";
+import {
+ InitiativeFields,
+ draftFrom,
+ toInput,
+} from "@/components/portal/initiatives/form-fields";
import { trpc } from "@/lib/trpc";
import type { RouterOutputs } from "@query/api";
@@ -22,9 +27,6 @@ const statuses = [
{ value: "closed", label: "Closed" },
] as const;
-const field =
- "w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none";
-
function InitiativeForm({
initiative,
onDone,
@@ -34,13 +36,7 @@ function InitiativeForm({
}) {
const utils = trpc.useUtils();
const [error, setError] = useState(null);
- const [form, setForm] = useState({
- title: initiative?.title ?? "",
- summary: initiative?.summary ?? "",
- description: initiative?.description ?? "",
- commitment: initiative?.commitment ?? "",
- maxMembers: initiative?.maxMembers?.toString() ?? "",
- });
+ const [draft, setDraft] = useState(() => draftFrom(initiative));
const done = async () => {
await utils.initiative.listMine.invalidate();
@@ -57,22 +53,13 @@ function InitiativeForm({
});
const pending = create.isPending || update.isPending;
- const set = (key: keyof typeof form) => (value: string) =>
- setForm((prev) => ({ ...prev, [key]: value }));
-
return (
{
event.preventDefault();
setError(null);
- const values = {
- title: form.title.trim(),
- summary: form.summary.trim() || undefined,
- description: form.description.trim() || undefined,
- commitment: form.commitment.trim() || undefined,
- maxMembers: form.maxMembers ? Number(form.maxMembers) : null,
- };
+ const values = toInput(draft);
if (initiative) update.mutate({ ...values, id: initiative.id });
else create.mutate(values);
}}
@@ -81,48 +68,7 @@ function InitiativeForm({
{initiative ? "Edit initiative" : "New initiative"}
-
+
{error && {error}
}
diff --git a/sites/mainweb/components/portal/initiatives/chips.tsx b/sites/mainweb/components/portal/initiatives/chips.tsx
index 89866c83..3ed0089f 100644
--- a/sites/mainweb/components/portal/initiatives/chips.tsx
+++ b/sites/mainweb/components/portal/initiatives/chips.tsx
@@ -55,6 +55,11 @@ const initiativeTones: Record<
InitiativeState,
{ label: string; className: string }
> = {
+ proposed: {
+ label: "Waiting on review",
+ className: "bg-sky-500/15 text-sky-300",
+ },
+ declined: { label: "Not approved", className: "bg-white/5 text-white/50" },
draft: { label: "Draft", className: "bg-white/10 text-white/60" },
open: {
label: "Taking applications",
diff --git a/sites/mainweb/components/portal/initiatives/form-fields.tsx b/sites/mainweb/components/portal/initiatives/form-fields.tsx
new file mode 100644
index 00000000..c05f8e94
--- /dev/null
+++ b/sites/mainweb/components/portal/initiatives/form-fields.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+/**
+ * The initiative fields, shared by the leader's create/edit form and the
+ * member's proposal form. A proposal becomes the initiative on approval, so
+ * the two have to ask for exactly the same things — keeping two copies is how
+ * a field ends up collected in one and silently dropped in the other.
+ */
+
+export type InitiativeDraft = {
+ title: string;
+ summary: string;
+ description: string;
+ commitment: string;
+ maxMembers: string;
+};
+
+export const emptyDraft: InitiativeDraft = {
+ title: "",
+ summary: "",
+ description: "",
+ commitment: "",
+ maxMembers: "",
+};
+
+export function draftFrom(row?: {
+ title: string;
+ summary: string | null;
+ description: string | null;
+ commitment: string | null;
+ maxMembers: number | null;
+}): InitiativeDraft {
+ if (!row) return emptyDraft;
+ return {
+ title: row.title,
+ summary: row.summary ?? "",
+ description: row.description ?? "",
+ commitment: row.commitment ?? "",
+ maxMembers: row.maxMembers?.toString() ?? "",
+ };
+}
+
+/** Empty strings become undefined, which the router stores as an explicit null. */
+export function toInput(draft: InitiativeDraft) {
+ return {
+ title: draft.title.trim(),
+ summary: draft.summary.trim() || undefined,
+ description: draft.description.trim() || undefined,
+ commitment: draft.commitment.trim() || undefined,
+ maxMembers: draft.maxMembers ? Number(draft.maxMembers) : null,
+ };
+}
+
+const field =
+ "w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none";
+
+export function InitiativeFields({
+ draft,
+ onChange,
+}: {
+ draft: InitiativeDraft;
+ onChange: (draft: InitiativeDraft) => void;
+}) {
+ const set = (key: keyof InitiativeDraft) => (value: string) =>
+ onChange({ ...draft, [key]: value });
+
+ return (
+
+ );
+}
From fa71f8feca888b194395c0401c71ec6fca71c8fc Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Wed, 5 Aug 2026 17:18:49 -0400
Subject: [PATCH 03/10] more
---
GCP_SETUP.md | 4 +-
README.md | 84 ++-
package.json | 2 +-
.../hackathon-interest.test.ts | 309 ++++++++
.../.internal-tests/initiative-edge.test.ts | 688 ++++++++++++++++++
.../.internal-tests/participant-edge.test.ts | 67 +-
.../src/.internal-tests/resilience.test.ts | 27 +-
.../api/src/.internal-tests/routers.test.ts | 5 +-
packages/api/src/middleware/cache.ts | 6 +-
packages/api/src/middleware/procedures.ts | 20 +-
packages/api/src/routers/hackathon/crud.ts | 10 +-
packages/api/src/routers/hackathon/index.ts | 2 +
.../api/src/routers/hackathon/interest.ts | 184 +++++
packages/api/src/routers/initiative.ts | 334 ++++-----
packages/api/src/services/portal-context.ts | 39 +-
packages/api/src/trpc.ts | 4 +
packages/db/src/schemas/hackathons.ts | 79 ++
packages/db/src/schemas/initiatives.ts | 44 +-
packages/db/src/services/membership.test.ts | 126 +++-
packages/db/src/services/membership.ts | 18 +-
sites/hacklytics2027/app/layout.tsx | 9 +-
sites/hacklytics2027/app/page.tsx | 5 +-
sites/hacklytics2027/components/Navbar.tsx | 9 +-
sites/hacklytics2027/lib/links.ts | 19 +
.../app/(portal)/admin/initiatives/page.tsx | 4 +-
.../api/cron/cleanup-audit-logs/route.ts | 38 +-
sites/mainweb/app/(portal)/dashboard/page.tsx | 86 ++-
.../mainweb/app/(portal)/hackathons/page.tsx | 9 +
.../mainweb/app/(portal)/hacklytics/page.tsx | 405 +++++++++++
sites/mainweb/app/(portal)/lead/page.tsx | 2 +-
sites/mainweb/app/(portal)/login/page.tsx | 41 +-
sites/mainweb/app/(portal)/settings/page.tsx | 2 +-
sites/mainweb/app/status/page.tsx | 10 +-
sites/mainweb/components/Navbar/index.tsx | 18 +-
.../admin/hackathons/AttendeesTab.tsx | 2 +-
.../admin/hackathons/CreateHackathonForm.tsx | 14 +-
.../admin/hackathons/EditHackathonForm.tsx | 1 +
.../components/admin/hackathons/JudgesTab.tsx | 2 +-
.../components/admin/hackathons/constants.ts | 10 +
.../components/portal/PortalWrapper.tsx | 8 +-
sites/mainweb/proxy.ts | 148 ++--
41 files changed, 2443 insertions(+), 451 deletions(-)
create mode 100644 packages/api/src/.internal-tests/hackathon-interest.test.ts
create mode 100644 packages/api/src/.internal-tests/initiative-edge.test.ts
create mode 100644 packages/api/src/routers/hackathon/interest.ts
create mode 100644 sites/hacklytics2027/lib/links.ts
create mode 100644 sites/mainweb/app/(portal)/hacklytics/page.tsx
diff --git a/GCP_SETUP.md b/GCP_SETUP.md
index 15260bd3..bd730a9f 100644
--- a/GCP_SETUP.md
+++ b/GCP_SETUP.md
@@ -48,10 +48,10 @@ This will pull the following from GCP:
## 4. Running the App
-To run the entire stack (Main Web + Discord Bot) in development mode:
+To run every workspace in development mode:
```bash
-pnpm dev:full
+pnpm dev
```
## 5. Troubleshooting
diff --git a/README.md b/README.md
index ad541ccc..c3de9958 100644
--- a/README.md
+++ b/README.md
@@ -44,12 +44,13 @@ in `drizzle.config.ts`.
| `admins.ts` | `admin` |
| `hackathons.ts` | `hackathon`, `hackathon_team`, `hackathon_participant`, `hackathon_project`, `hackathon_event`, `hackathon_event_attendee` |
| `judge.ts` | `judge`, `judge_assignment`, `judging_project`, `judge_vote`, `judge_queue`, `hackathon_map` |
+| `initiatives.ts` | `project_leader`, `initiative`, `initiative_application` |
| `events.ts` | `event`, `event_check_in` |
| `stripe.ts` | `stripe_payment`, `user_account_link` |
| `security.ts` | `audit_logs` (+ `security_severity` enum) |
| `settings.ts` | `system_settings` |
-26 tables in total. Two entities anchor the graph:
+Two entities anchor the graph:
- **`user`** — every identity-bearing table cascades from it: `account`,
`session`, `admin`, `user_profile`, `member`, `judge`, `event`,
@@ -62,6 +63,87 @@ in `drizzle.config.ts`.
Nearly all foreign keys are `onDelete: "cascade"`, so deleting a user or a
hackathon removes its dependent rows rather than orphaning them.
+### Club and hackathon are separate
+
+Two aspects share the database and touch nowhere:
+
+- **Hackathon** — editions, registration, teams, project submission, judging.
+ Everything here hangs off a `hackathon` row.
+- **Club** — `initiative`, its applications, and the `project_leader` role.
+ Deliberately **not** scoped to a hackathon. A club project runs whenever
+ somebody leads one, and leading is a standing appointment rather than a
+ yearly re-grant. Nothing in this half is ever judged; judges only score
+ `hackathon_project`.
+
+`member` is the one crossing case: a paid year still hangs off an edition, so
+membership resolves the current hackathon even though initiatives do not.
+
+#### One-off step before the first push that carries this
+
+`migrate:push` cannot work this one out on its own. `project_leader` moved from
+`unique(user_id, hackathon_id)` to `unique(user_id)`, so anybody appointed in
+more than one edition has more than one row; drizzle-kit fails building the new
+index partway and leaves the schema half-applied. Run this against the target
+database **once, before** the push. Every statement is guarded, so it is safe to
+re-run.
+
+```sql
+BEGIN;
+
+-- Collapse duplicate leader appointments to one row per person. Keeps the
+-- oldest row, so created_at still reads as when they were first appointed, and
+-- keeps the role switched on if ANY of their rows was active — dropping an
+-- active appointment here silently locks a leader out of their own initiatives.
+WITH ranked AS (
+ SELECT
+ id,
+ user_id,
+ bool_or(is_active) OVER (PARTITION BY user_id) AS any_active,
+ row_number() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn
+ FROM project_leader
+)
+UPDATE project_leader AS pl
+SET is_active = ranked.any_active
+FROM ranked
+WHERE pl.id = ranked.id
+ AND ranked.rn = 1
+ AND pl.is_active IS DISTINCT FROM ranked.any_active;
+
+DELETE FROM project_leader
+WHERE id IN (
+ SELECT id FROM (
+ SELECT
+ id,
+ row_number() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn
+ FROM project_leader
+ ) dupes
+ WHERE rn > 1
+);
+
+-- Drop the edition columns and everything hanging off them.
+ALTER TABLE project_leader
+ DROP CONSTRAINT IF EXISTS unique_project_leader_per_hackathon;
+DROP INDEX IF EXISTS project_leader_hackathon_id_idx;
+ALTER TABLE project_leader DROP COLUMN IF EXISTS hackathon_id;
+
+DROP INDEX IF EXISTS initiative_hackathon_id_idx;
+ALTER TABLE initiative DROP COLUMN IF EXISTS hackathon_id;
+
+-- The constraint the new schema expects. Added here rather than left to push,
+-- so a collision surfaces inside this transaction where it rolls back.
+ALTER TABLE project_leader
+ DROP CONSTRAINT IF EXISTS unique_project_leader;
+ALTER TABLE project_leader
+ ADD CONSTRAINT unique_project_leader UNIQUE (user_id);
+
+COMMIT;
+```
+
+Initiatives themselves are untouched. Rows that were invisible because they
+belonged to a past edition become visible again — that is the point, they were
+club projects an edition rollover hid. Archive any that should not come back
+from the leader screen afterwards.
+
### Working with the schema
```bash
diff --git a/package.json b/package.json
index 2745f5e3..ec042a06 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
"lint": "turbo run lint",
"format": "prettier --write .",
"typecheck": "turbo run typecheck",
- "test": "vitest run packages/api"
+ "test": "vitest run packages/api packages/db"
},
"dependencies": {
"next": "16.3.0",
diff --git a/packages/api/src/.internal-tests/hackathon-interest.test.ts b/packages/api/src/.internal-tests/hackathon-interest.test.ts
new file mode 100644
index 00000000..21ab2b14
--- /dev/null
+++ b/packages/api/src/.internal-tests/hackathon-interest.test.ts
@@ -0,0 +1,309 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { appRouter } from "../root";
+import { cache } from "../middleware/cache";
+import { hackathonInterest } from "@query/db";
+
+/**
+ * The interest list for an announced-but-not-open edition.
+ *
+ * The rules worth pinning down are the ones about WHICH editions accept
+ * interest: a draft must be indistinguishable from a made-up id, and an edition
+ * that has actually opened must send people to register rather than quietly
+ * taking a second, weaker signal.
+ */
+
+const mockFindFirst = vi.fn();
+const mockInsert = vi.fn();
+const mockDelete = vi.fn();
+const mockSelectRows = vi.fn(() => [] as unknown[]);
+
+vi.mock("@query/db", () => {
+ const selectChain = () => {
+ const node: any = {
+ from: () => node,
+ innerJoin: () => node,
+ where: () => node,
+ orderBy: () => node,
+ limit: () => Promise.resolve(mockSelectRows()),
+ then: (ok: any, err: any) => Promise.resolve(mockSelectRows()).then(ok, err),
+ };
+ return node;
+ };
+
+ const table = (name: string) => ({
+ findFirst: (...args: any[]) => mockFindFirst(name, ...args),
+ findMany: async () => [],
+ });
+
+ return {
+ db: {
+ query: {
+ admins: table("admins"),
+ users: table("users"),
+ hackathons: table("hackathons"),
+ hackathonInterest: table("hackathonInterest"),
+ members: table("members"),
+ projectLeaders: table("projectLeaders"),
+ judges: table("judges"),
+ },
+ select: selectChain,
+ insert: (...insertArgs: any[]) => ({
+ values: (...valArgs: any[]) => {
+ const val = mockInsert("insert", insertArgs, valArgs);
+ return Object.assign(Promise.resolve(val), {
+ returning: vi.fn().mockResolvedValue(val),
+ onConflictDoUpdate: (...conflictArgs: any[]) => {
+ mockInsert("conflict", insertArgs, conflictArgs);
+ return Object.assign(Promise.resolve(val), {
+ returning: vi.fn().mockResolvedValue(val),
+ });
+ },
+ });
+ },
+ }),
+ delete: (...deleteArgs: any[]) => ({
+ where: (...wArgs: any[]) => {
+ const val = mockDelete("delete", deleteArgs, wArgs);
+ return Object.assign(Promise.resolve(val), {
+ returning: vi.fn().mockResolvedValue(val),
+ });
+ },
+ }),
+ },
+ admins: { userId: "user_id", isActive: "is_active", role: "role" },
+ users: { id: "id", name: "name", email: "email" },
+ hackathons: {
+ id: "id",
+ status: "status",
+ isPublic: "is_public",
+ startDate: "start_date",
+ },
+ members: { userId: "user_id", hackathonId: "hackathon_id" },
+ projectLeaders: { userId: "user_id", isActive: "is_active" },
+ judges: { userId: "user_id", isActive: "is_active" },
+ hackathonInterest: {
+ id: "id",
+ hackathonId: "hackathon_id",
+ userId: "user_id",
+ school: "school",
+ country: "country",
+ graduationYear: "graduation_year",
+ experience: "experience",
+ createdAt: "created_at",
+ },
+ };
+});
+
+import { db } from "@query/db";
+
+const HACK = "22222222-2222-4222-8222-222222222222";
+const VISITOR = "user_visitor";
+const ADMIN = "user_admin";
+
+const callerFor = (userId?: string) =>
+ appRouter.createCaller({
+ db,
+ session: userId ? { user: { id: userId } } : null,
+ userId,
+ cache,
+ clientIp: "127.0.0.1",
+ req: { headers: { get: () => null } },
+ } as never);
+
+/**
+ * Deliberately a made-up edition. Real names, dates and themes belong in the
+ * database, not in a fixture in a public repository — an unannounced event
+ * should not be readable from the test suite before it is announced.
+ */
+const announced = (overrides: Record = {}) => ({
+ id: HACK,
+ name: "Example Hackathon",
+ description: "A placeholder edition used only by this suite.",
+ location: "Somewhere",
+ startDate: new Date("2099-01-02T09:00:00Z"),
+ endDate: new Date("2099-01-04T21:00:00Z"),
+ theme: "Example Theme",
+ websiteUrl: "https://example.com",
+ status: "announced",
+ isPublic: true,
+ ...overrides,
+});
+
+const lookups = (opts: {
+ hackathon?: Record;
+ interest?: Record;
+ isAdmin?: boolean;
+}) => {
+ mockFindFirst.mockImplementation((tableName: string) => {
+ if (tableName === "hackathons") return opts.hackathon;
+ if (tableName === "hackathonInterest") return opts.interest;
+ if (tableName === "admins")
+ return opts.isAdmin ? { id: "ad_1", role: "admin", isActive: true } : undefined;
+ return undefined;
+ });
+};
+
+describe("Hackathon interest list", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockFindFirst.mockReset();
+ mockInsert.mockReset().mockReturnValue([]);
+ mockDelete.mockReset().mockReturnValue([]);
+ mockSelectRows.mockReset().mockReturnValue([]);
+ cache.clear();
+ });
+
+ describe("1. The announced edition", () => {
+ it("is readable without signing in", async () => {
+ // A signed-out stranger is the whole audience for this page.
+ lookups({ hackathon: announced() });
+
+ const res = await callerFor().hackathon.getUpcoming();
+ expect(res?.name).toBe("Example Hackathon");
+ expect(res?.theme).toBe("Example Theme");
+ });
+
+ it("answers null when nothing is announced", async () => {
+ lookups({ hackathon: undefined });
+ await expect(callerFor().hackathon.getUpcoming()).resolves.toBeNull();
+ });
+ });
+
+ describe("2. Which editions take interest", () => {
+ it("hides a draft edition behind NOT_FOUND", async () => {
+ // Confirming a draft exists would leak that staff are planning something.
+ lookups({ hackathon: announced({ status: "draft" }) });
+
+ await expect(
+ callerFor(VISITOR).hackathon.registerInterest({ hackathonId: HACK }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ expect(mockInsert).not.toHaveBeenCalled();
+ });
+
+ it("hides a non-public edition the same way", async () => {
+ lookups({ hackathon: announced({ isPublic: false }) });
+
+ await expect(
+ callerFor(VISITOR).hackathon.registerInterest({ hackathonId: HACK }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ });
+
+ it("sends people to register once the edition is open", async () => {
+ // Taking interest here would collect a weaker signal from somebody who
+ // could have had an actual place.
+ lookups({ hackathon: announced({ status: "open" }) });
+
+ await expect(
+ callerFor(VISITOR).hackathon.registerInterest({ hackathonId: HACK }),
+ ).rejects.toMatchObject({
+ code: "BAD_REQUEST",
+ message: expect.stringContaining("Registration is open"),
+ });
+ });
+
+ it("refuses once the edition is over", async () => {
+ lookups({ hackathon: announced({ status: "completed" }) });
+
+ await expect(
+ callerFor(VISITOR).hackathon.registerInterest({ hackathonId: HACK }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ it("requires signing in", async () => {
+ lookups({ hackathon: announced() });
+
+ await expect(
+ callerFor().hackathon.registerInterest({ hackathonId: HACK }),
+ ).rejects.toMatchObject({ code: "UNAUTHORIZED" });
+ });
+ });
+
+ describe("3. Joining and leaving", () => {
+ it("upserts, so a second submit edits one entry", async () => {
+ lookups({ hackathon: announced() });
+
+ const res = await callerFor(VISITOR).hackathon.registerInterest({
+ hackathonId: HACK,
+ school: "Georgia Institute of Technology",
+ country: "United States",
+ graduationYear: 2029,
+ experience: "first",
+ });
+
+ expect(res.onList).toBe(true);
+ const [insert] = mockInsert.mock.calls;
+ expect(insert![2][0]).toMatchObject({
+ hackathonId: HACK,
+ userId: VISITOR,
+ school: "Georgia Institute of Technology",
+ country: "United States",
+ graduationYear: 2029,
+ experience: "first",
+ });
+ // The unique index is what makes a double submit safe, so the write has
+ // to actually name it rather than relying on the earlier read.
+ const conflict = mockInsert.mock.calls.find((c) => c[0] === "conflict");
+ expect(conflict).toBeDefined();
+ });
+
+ it("stores a blank answer as null rather than an empty string", async () => {
+ lookups({ hackathon: announced() });
+
+ await callerFor(VISITOR).hackathon.registerInterest({
+ hackathonId: HACK,
+ school: "",
+ country: "",
+ });
+
+ const [insert] = mockInsert.mock.calls;
+ expect(insert![2][0].school).toBeNull();
+ expect(insert![2][0].country).toBeNull();
+ expect(insert![2][0].graduationYear).toBeNull();
+ });
+
+ it("lets somebody leave the list", async () => {
+ lookups({ hackathon: announced() });
+
+ const res = await callerFor(VISITOR).hackathon.withdrawInterest({
+ hackathonId: HACK,
+ });
+
+ expect(res.onList).toBe(false);
+ expect(mockDelete.mock.calls[0]![1][0]).toBe(hackathonInterest);
+ });
+
+ it("makes leaving twice a no-op rather than an error", async () => {
+ lookups({ hackathon: announced() });
+ mockDelete.mockReturnValue([]);
+
+ await expect(
+ callerFor(VISITOR).hackathon.withdrawInterest({ hackathonId: HACK }),
+ ).resolves.toEqual({ onList: false });
+ });
+ });
+
+ describe("4. The list itself", () => {
+ it("is refused to a caller who is not an admin", async () => {
+ lookups({ hackathon: announced(), isAdmin: false });
+
+ await expect(
+ callerFor(VISITOR).hackathon.listInterest({ hackathonId: HACK }),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+ });
+
+ it("is returned to an admin", async () => {
+ lookups({ hackathon: announced(), isAdmin: true });
+ mockSelectRows.mockReturnValue([
+ { userId: VISITOR, email: "ada@example.com", school: null },
+ ]);
+
+ const rows = await callerFor(ADMIN).hackathon.listInterest({
+ hackathonId: HACK,
+ });
+ expect(rows).toHaveLength(1);
+ // Read through the join rather than a stored copy, so somebody who
+ // changes their address stays reachable.
+ expect(rows[0]!.email).toBe("ada@example.com");
+ });
+ });
+});
diff --git a/packages/api/src/.internal-tests/initiative-edge.test.ts b/packages/api/src/.internal-tests/initiative-edge.test.ts
new file mode 100644
index 00000000..871aff0c
--- /dev/null
+++ b/packages/api/src/.internal-tests/initiative-edge.test.ts
@@ -0,0 +1,688 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { appRouter } from "../root";
+import { cache } from "../middleware/cache";
+import {
+ initiatives,
+ initiativeApplications,
+ projectLeaders,
+} from "@query/db";
+
+/**
+ * Club initiatives: the leader role, the ownership gate, and the join flow.
+ *
+ * The half of the platform that is deliberately NOT scoped to a hackathon
+ * edition, so a good third of what is asserted here is that an edition — or the
+ * absence of one — changes nothing.
+ */
+
+const mockFindFirst = vi.fn();
+const mockInsert = vi.fn();
+const mockUpdate = vi.fn();
+const mockDelete = vi.fn();
+
+/**
+ * Rows a `.select()` chain resolves to, keyed by the table in `.from()`.
+ * Every terminal on the chain funnels through it, so a test steers the seat
+ * count and the list queries by table rather than by call order.
+ */
+let onSelect: (table: unknown) => unknown[] = () => [];
+
+vi.mock("@query/db", async () => {
+ const { createTransactionMock } = await import("./_db-tx-mock");
+
+ const table = (name: string) => ({
+ findFirst: (...args: any[]) => mockFindFirst(name, ...args),
+ findMany: async () => [],
+ });
+
+ // Mirrors drizzle's builder closely enough for the chains this router uses:
+ // .from().innerJoin().where().orderBy().limit(), .where().groupBy(), an
+ // awaited .where(), and .where().for("update").
+ const selectChain = () => {
+ let from: unknown;
+ const rows = () => Promise.resolve(onSelectRef.current(from));
+ const node: any = {
+ from: (t: unknown) => ((from = t), node),
+ innerJoin: () => node,
+ where: () => node,
+ orderBy: () => node,
+ groupBy: () => rows(),
+ limit: () => rows(),
+ for: () => rows(),
+ then: (ok: any, err: any) => rows().then(ok, err),
+ };
+ return node;
+ };
+
+ return {
+ db: {
+ transaction: createTransactionMock({
+ base: () => db,
+ insert: (...a: any[]) => mockInsert(...a),
+ update: (...a: any[]) => mockUpdate(...a),
+ select: (...a: any[]) => onSelectRef.current(a[2]?.[0]),
+ }),
+ query: {
+ admins: table("admins"),
+ users: table("users"),
+ hackathons: table("hackathons"),
+ members: table("members"),
+ projectLeaders: table("projectLeaders"),
+ initiatives: table("initiatives"),
+ initiativeApplications: table("initiativeApplications"),
+ },
+ select: selectChain,
+ insert: (...insertArgs: any[]) => ({
+ values: (...valArgs: any[]) => {
+ const val = mockInsert("insert", insertArgs, valArgs);
+ return Object.assign(Promise.resolve(val), {
+ returning: vi.fn().mockResolvedValue(val),
+ });
+ },
+ }),
+ update: (...updateArgs: any[]) => ({
+ set: (...setArgs: any[]) => ({
+ where: (...wArgs: any[]) => {
+ const val = mockUpdate("update", updateArgs, setArgs, wArgs);
+ return Object.assign(Promise.resolve(val), {
+ returning: vi.fn().mockResolvedValue(val),
+ });
+ },
+ }),
+ }),
+ delete: (...deleteArgs: any[]) => ({
+ where: (...wArgs: any[]) => {
+ const val = mockDelete("delete", deleteArgs, wArgs);
+ return Object.assign(Promise.resolve(val), {
+ returning: vi.fn().mockResolvedValue(val),
+ });
+ },
+ }),
+ },
+ admins: { userId: "user_id", isActive: "is_active", role: "role" },
+ users: { id: "id", name: "name", email: "email", image: "image" },
+ hackathons: { id: "id", status: "status", startDate: "start_date", endDate: "end_date" },
+ members: { userId: "user_id", hackathonId: "hackathon_id" },
+ projectLeaders: {
+ id: "id",
+ userId: "user_id",
+ isActive: "is_active",
+ createdAt: "created_at",
+ },
+ initiatives: {
+ id: "id",
+ leaderUserId: "leader_user_id",
+ title: "title",
+ summary: "summary",
+ description: "description",
+ commitment: "commitment",
+ status: "status",
+ maxMembers: "max_members",
+ archivedAt: "archived_at",
+ reviewedAt: "reviewed_at",
+ reviewNote: "review_note",
+ createdAt: "created_at",
+ },
+ initiativeApplications: {
+ id: "id",
+ initiativeId: "initiative_id",
+ userId: "user_id",
+ status: "status",
+ pitch: "pitch",
+ appliedAt: "applied_at",
+ decidedAt: "decided_at",
+ },
+ };
+});
+
+// The mock factory is hoisted above `let onSelect`, so it may only close over a
+// container it can read later — not the binding itself.
+const onSelectRef = { get current() { return onSelect; } };
+
+import { db } from "@query/db";
+
+const LEADER = "user_leader";
+const OTHER_LEADER = "user_other_leader";
+const MEMBER = "user_member";
+const ADMIN = "user_admin";
+const INITIATIVE = "11111111-1111-4111-8111-111111111111";
+const DAY = 24 * 60 * 60 * 1000;
+
+const callerFor = (userId: string) =>
+ appRouter.createCaller({
+ db,
+ session: { user: { id: userId } },
+ userId,
+ cache,
+ clientIp: "127.0.0.1",
+ req: undefined,
+ } as never);
+
+/** An initiative open to applications, led by LEADER. */
+const openInitiative = (overrides: Record = {}) => ({
+ id: INITIATIVE,
+ leaderUserId: LEADER,
+ title: "Sensor Net",
+ summary: null,
+ description: null,
+ commitment: null,
+ status: "open",
+ maxMembers: 3,
+ archivedAt: null,
+ reviewedAt: null,
+ reviewedById: null,
+ reviewNote: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ ...overrides,
+});
+
+/**
+ * Table-keyed lookups. `who` decides the leader/admin/member answers, so each
+ * test states who is calling rather than restating the whole fixture.
+ */
+const lookups = (opts: {
+ isLeader?: string | null;
+ isAdmin?: string | null;
+ initiative?: Record | undefined;
+ application?: Record | undefined;
+ member?: Record | undefined;
+ hackathon?: Record | undefined;
+}) => {
+ const {
+ isLeader = null,
+ isAdmin = null,
+ initiative,
+ application,
+ member,
+ hackathon = { id: "hack_1" },
+ } = opts;
+
+ mockFindFirst.mockImplementation((tableName: string, args?: any) => {
+ switch (tableName) {
+ case "projectLeaders":
+ return isLeader ? { id: "pl_1", userId: isLeader, isActive: true } : undefined;
+ case "admins":
+ return isAdmin ? { id: "ad_1", userId: isAdmin, role: "admin", isActive: true } : undefined;
+ case "hackathons":
+ return hackathon;
+ case "initiatives":
+ return initiative;
+ case "initiativeApplications":
+ return application;
+ case "members":
+ return member;
+ case "users":
+ return { id: (args?.where && "id") || "id" };
+ default:
+ return undefined;
+ }
+ });
+};
+
+/** A membership that has not run out — what applying requires. */
+const activeMember = { isActive: true, membershipEndDate: new Date(Date.now() + 30 * DAY) };
+
+const insertedInto = (t: unknown) =>
+ mockInsert.mock.calls.filter((c) => c[1]?.[0] === t);
+
+describe("Club initiatives", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockFindFirst.mockReset();
+ mockInsert.mockReset().mockReturnValue([{ id: INITIATIVE }]);
+ mockUpdate.mockReset().mockReturnValue([{ id: INITIATIVE, status: "open" }]);
+ mockDelete.mockReset().mockReturnValue([]);
+ onSelect = () => [];
+ cache.clear();
+ });
+
+ // ===================================================================
+ describe("1. The leader role is not an edition", () => {
+ it("lets a leader in when no hackathon exists at all", async () => {
+ // The gate used to resolve the current edition first and throw NOT_FOUND
+ // when there was none, so a club with no event on the calendar had no
+ // project leaders — every leader screen 404'd out of season.
+ lookups({ isLeader: LEADER, hackathon: undefined });
+
+ await expect(callerFor(LEADER).initiative.listMine()).resolves.toEqual([]);
+ });
+
+ it("refuses somebody who holds no leader row", async () => {
+ lookups({ isLeader: null });
+
+ await expect(callerFor(MEMBER).initiative.listMine()).rejects.toMatchObject({
+ code: "FORBIDDEN",
+ });
+ });
+
+ it("lets an admin cover for a leader without a leader row", async () => {
+ lookups({ isLeader: null, isAdmin: ADMIN });
+
+ await expect(callerFor(ADMIN).initiative.listMine()).resolves.toEqual([]);
+ });
+ });
+
+ // ===================================================================
+ describe("2. Ownership", () => {
+ it("hides another leader's initiative behind NOT_FOUND, not FORBIDDEN", async () => {
+ // FORBIDDEN would confirm the id exists, which is the one thing guessing
+ // ids is good for.
+ lookups({
+ isLeader: OTHER_LEADER,
+ initiative: openInitiative({ leaderUserId: LEADER }),
+ });
+
+ await expect(
+ callerFor(OTHER_LEADER).initiative.getById({ id: INITIATIVE }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ });
+
+ it("lets the leader who owns it through", async () => {
+ lookups({ isLeader: LEADER, initiative: openInitiative() });
+ onSelect = () => [];
+
+ const res = await callerFor(LEADER).initiative.getById({ id: INITIATIVE });
+ expect(res.initiative.id).toBe(INITIATIVE);
+ });
+
+ it("lets an admin through to somebody else's initiative", async () => {
+ lookups({ isAdmin: ADMIN, initiative: openInitiative() });
+
+ const res = await callerFor(ADMIN).initiative.getById({ id: INITIATIVE });
+ expect(res.initiative.id).toBe(INITIATIVE);
+ });
+
+ it("refuses to edit another leader's initiative", async () => {
+ lookups({
+ isLeader: OTHER_LEADER,
+ initiative: openInitiative({ leaderUserId: LEADER }),
+ });
+
+ await expect(
+ callerFor(OTHER_LEADER).initiative.update({
+ id: INITIATIVE,
+ title: "Hijacked",
+ }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ expect(mockUpdate).not.toHaveBeenCalled();
+ });
+ });
+
+ // ===================================================================
+ describe("3. Creating on somebody's behalf", () => {
+ it("refuses an admin who names nobody", async () => {
+ // Defaulting the leader to the caller stored the ADMIN as leader and put
+ // their name in front of members.
+ lookups({ isLeader: null, isAdmin: ADMIN });
+
+ await expect(
+ callerFor(ADMIN).initiative.create({ title: "Sensor Net" }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ it("refuses naming somebody who is not a leader", async () => {
+ mockFindFirst.mockImplementation((tableName: string) => {
+ if (tableName === "admins") return { id: "ad_1", role: "admin", isActive: true };
+ if (tableName === "hackathons") return { id: "hack_1" };
+ // No projectLeaders row for the named user.
+ return undefined;
+ });
+
+ await expect(
+ callerFor(ADMIN).initiative.create({
+ title: "Sensor Net",
+ leaderUserId: MEMBER,
+ }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ it("refuses a non-admin leader creating for someone else", async () => {
+ lookups({ isLeader: LEADER });
+
+ await expect(
+ callerFor(LEADER).initiative.create({
+ title: "Sensor Net",
+ leaderUserId: OTHER_LEADER,
+ }),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+ });
+
+ it("creates as a draft so nothing reaches members unopened", async () => {
+ lookups({ isLeader: LEADER });
+
+ await callerFor(LEADER).initiative.create({ title: "Sensor Net" });
+
+ const [call] = insertedInto(initiatives);
+ expect(call).toBeDefined();
+ expect(call![2][0]).toMatchObject({
+ leaderUserId: LEADER,
+ status: "draft",
+ // Leader plus three accepted members is a team of four.
+ maxMembers: 3,
+ });
+ // The column is gone; writing one would be a schema error in production.
+ expect(call![2][0]).not.toHaveProperty("hackathonId");
+ });
+
+ it("leaves an initiative uncapped when the leader clears the cap", async () => {
+ lookups({ isLeader: LEADER });
+
+ await callerFor(LEADER).initiative.create({
+ title: "Reading group",
+ maxMembers: null,
+ });
+
+ const [call] = insertedInto(initiatives);
+ expect(call![2][0].maxMembers).toBeNull();
+ });
+ });
+
+ // ===================================================================
+ describe("4. Applying", () => {
+ it("needs a membership that has not lapsed", async () => {
+ lookups({
+ initiative: openInitiative(),
+ member: { isActive: true, membershipEndDate: new Date(Date.now() - DAY) },
+ });
+
+ await expect(
+ callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+ });
+
+ it.each(["draft", "proposed", "declined"])(
+ "answers a %s initiative exactly like a made-up id",
+ async (status) => {
+ // BAD_REQUEST here would tell a stranger that somebody pitched this.
+ lookups({
+ initiative: openInitiative({ status }),
+ member: activeMember,
+ });
+
+ await expect(
+ callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ },
+ );
+
+ it("answers an archived initiative the same way", async () => {
+ lookups({
+ initiative: openInitiative({ archivedAt: new Date() }),
+ member: activeMember,
+ });
+
+ await expect(
+ callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ });
+
+ it("refuses the leader applying to their own initiative", async () => {
+ lookups({ initiative: openInitiative(), member: activeMember });
+
+ await expect(
+ callerFor(LEADER).initiative.requestToJoin({ initiativeId: INITIATIVE }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ it("refuses when every seat is taken", async () => {
+ lookups({
+ initiative: openInitiative({ maxMembers: 3 }),
+ member: activeMember,
+ });
+ onSelect = (t) => (t === initiativeApplications ? [{ taken: 3 }] : []);
+
+ await expect(
+ callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ it("tells a repeat applicant where they stand instead of counting them twice", async () => {
+ lookups({
+ initiative: openInitiative(),
+ application: { id: "app_1", status: "pending" },
+ member: activeMember,
+ });
+
+ await expect(
+ callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }),
+ ).rejects.toMatchObject({ code: "CONFLICT" });
+ });
+
+ it("reuses the row when somebody who withdrew applies again", async () => {
+ // The unique index still holds that row, so a second insert would collide.
+ lookups({
+ initiative: openInitiative(),
+ application: { id: "app_1", status: "withdrawn" },
+ member: activeMember,
+ });
+ onSelect = (t) => (t === initiativeApplications ? [{ taken: 0 }] : []);
+
+ const res = await callerFor(MEMBER).initiative.requestToJoin({
+ initiativeId: INITIATIVE,
+ });
+
+ expect(res.status).toBe("pending");
+ expect(insertedInto(initiativeApplications)).toHaveLength(0);
+ expect(mockUpdate).toHaveBeenCalled();
+ });
+ });
+
+ // ===================================================================
+ describe("5. Deciding", () => {
+ it("refuses to decide on somebody who withdrew", async () => {
+ lookups({
+ isLeader: LEADER,
+ initiative: openInitiative(),
+ application: { id: "app_1", status: "withdrawn" },
+ });
+
+ await expect(
+ callerFor(LEADER).initiative.decide({
+ initiativeId: INITIATIVE,
+ userId: MEMBER,
+ decision: "accepted",
+ }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ it("makes a repeat of the same decision a no-op", async () => {
+ // Two officers on the same queue must not restamp decidedAt.
+ lookups({
+ isLeader: LEADER,
+ initiative: openInitiative(),
+ application: { id: "app_1", status: "accepted" },
+ });
+
+ const res = await callerFor(LEADER).initiative.decide({
+ initiativeId: INITIATIVE,
+ userId: MEMBER,
+ decision: "accepted",
+ });
+
+ expect(res.status).toBe("accepted");
+ expect(mockUpdate).not.toHaveBeenCalled();
+ });
+
+ it("refuses an acceptance that would exceed the cap", async () => {
+ lookups({
+ isLeader: LEADER,
+ initiative: openInitiative({ maxMembers: 3 }),
+ application: { id: "app_1", status: "pending" },
+ });
+ onSelect = (t) => (t === initiativeApplications ? [{ taken: 3 }] : []);
+
+ await expect(
+ callerFor(LEADER).initiative.decide({
+ initiativeId: INITIATIVE,
+ userId: MEMBER,
+ decision: "accepted",
+ }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ it("lets a rejection through when the initiative is full", async () => {
+ // A full initiative can still say no — the cap only bounds acceptances.
+ lookups({
+ isLeader: LEADER,
+ initiative: openInitiative({ maxMembers: 3 }),
+ application: { id: "app_1", status: "pending" },
+ });
+ onSelect = (t) => (t === initiativeApplications ? [{ taken: 3 }] : []);
+
+ const res = await callerFor(LEADER).initiative.decide({
+ initiativeId: INITIATIVE,
+ userId: MEMBER,
+ decision: "rejected",
+ });
+ expect(res.status).toBe("rejected");
+ });
+
+ it("refuses a leader deciding on another leader's applicant", async () => {
+ lookups({
+ isLeader: OTHER_LEADER,
+ initiative: openInitiative({ leaderUserId: LEADER }),
+ application: { id: "app_1", status: "pending" },
+ });
+
+ await expect(
+ callerFor(OTHER_LEADER).initiative.decide({
+ initiativeId: INITIATIVE,
+ userId: MEMBER,
+ decision: "accepted",
+ }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ });
+ });
+
+ // ===================================================================
+ describe("6. Proposals", () => {
+ it("caps a member at three waiting proposals", async () => {
+ lookups({ member: activeMember });
+ onSelect = (t) => (t === initiatives ? [{ total: 3 }] : []);
+
+ await expect(
+ callerFor(MEMBER).initiative.propose({ title: "Sensor Net" }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ it("files the proposal as the row itself, proposer as leader", async () => {
+ lookups({ member: activeMember });
+ onSelect = (t) => (t === initiatives ? [{ total: 0 }] : []);
+
+ await callerFor(MEMBER).initiative.propose({ title: "Sensor Net" });
+
+ const [call] = insertedInto(initiatives);
+ expect(call![2][0]).toMatchObject({
+ leaderUserId: MEMBER,
+ status: "proposed",
+ });
+ });
+
+ it("needs an active membership to propose", async () => {
+ lookups({ member: undefined });
+
+ await expect(
+ callerFor(MEMBER).initiative.propose({ title: "Sensor Net" }),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+ });
+
+ it("refuses to withdraw a proposal that was already reviewed", async () => {
+ // The delete is scoped to status = proposed, so an approved one matches
+ // no row and the caller is told why rather than told it worked.
+ lookups({});
+ mockDelete.mockReturnValue([]);
+
+ await expect(
+ callerFor(MEMBER).initiative.withdrawProposal({ id: INITIATIVE }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ });
+ });
+
+ // ===================================================================
+ describe("7. Approving a proposal", () => {
+ it("grants the leader role without an edition on it", async () => {
+ mockFindFirst.mockImplementation((tableName: string) => {
+ if (tableName === "admins") return { id: "ad_1", role: "admin", isActive: true };
+ if (tableName === "hackathons") return { id: "hack_1" };
+ if (tableName === "initiatives")
+ return openInitiative({ status: "proposed", leaderUserId: MEMBER });
+ if (tableName === "projectLeaders") return undefined;
+ return undefined;
+ });
+
+ await callerFor(ADMIN).initiative.reviewProposal({
+ id: INITIATIVE,
+ decision: "approve",
+ });
+
+ const [call] = insertedInto(projectLeaders);
+ expect(call).toBeDefined();
+ expect(call![2][0]).toMatchObject({ userId: MEMBER, isActive: true });
+ expect(call![2][0]).not.toHaveProperty("hackathonId");
+ });
+
+ it("restores a revoked role rather than colliding with the unique index", async () => {
+ mockFindFirst.mockImplementation((tableName: string) => {
+ if (tableName === "admins") return { id: "ad_1", role: "admin", isActive: true };
+ if (tableName === "hackathons") return { id: "hack_1" };
+ if (tableName === "initiatives")
+ return openInitiative({ status: "proposed", leaderUserId: MEMBER });
+ if (tableName === "projectLeaders")
+ return { id: "pl_1", userId: MEMBER, isActive: false };
+ return undefined;
+ });
+
+ await callerFor(ADMIN).initiative.reviewProposal({
+ id: INITIATIVE,
+ decision: "approve",
+ });
+
+ expect(insertedInto(projectLeaders)).toHaveLength(0);
+ expect(mockUpdate).toHaveBeenCalled();
+ });
+
+ it("refuses to review the same proposal twice", async () => {
+ mockFindFirst.mockImplementation((tableName: string) => {
+ if (tableName === "admins") return { id: "ad_1", role: "admin", isActive: true };
+ if (tableName === "hackathons") return { id: "hack_1" };
+ if (tableName === "initiatives") return openInitiative({ status: "draft" });
+ return undefined;
+ });
+
+ await expect(
+ callerFor(ADMIN).initiative.reviewProposal({
+ id: INITIATIVE,
+ decision: "approve",
+ }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+ });
+
+ // ===================================================================
+ describe("8. Status and archiving", () => {
+ it("refuses a status change while archived", async () => {
+ lookups({
+ isLeader: LEADER,
+ initiative: openInitiative({ archivedAt: new Date() }),
+ });
+
+ await expect(
+ callerFor(LEADER).initiative.setStatus({ id: INITIATIVE, status: "open" }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ it("shuts the door when archiving", async () => {
+ lookups({ isLeader: LEADER, initiative: openInitiative() });
+
+ await callerFor(LEADER).initiative.setArchived({
+ id: INITIATIVE,
+ archived: true,
+ });
+
+ const [, , setArgs] = mockUpdate.mock.calls[0]!;
+ expect(setArgs[0]).toMatchObject({ status: "closed" });
+ expect(setArgs[0].archivedAt).toBeInstanceOf(Date);
+ });
+ });
+});
diff --git a/packages/api/src/.internal-tests/participant-edge.test.ts b/packages/api/src/.internal-tests/participant-edge.test.ts
index 7bfa1057..d71f3384 100644
--- a/packages/api/src/.internal-tests/participant-edge.test.ts
+++ b/packages/api/src/.internal-tests/participant-edge.test.ts
@@ -7,6 +7,7 @@ import {
hackathonParticipants,
hackathonTeams,
hackathonProjects,
+ members,
membershipHistory,
} from "@query/db";
import { __onRollback } from "./_db-tx-mock";
@@ -901,7 +902,7 @@ describe("Participant edge cases", () => {
return callerFor("user_a");
};
- it("reports an expired membership as a member whose days remaining went negative", async () => {
+ it("reports an expired membership as lapsed, with days remaining gone negative", async () => {
const caller = memberCaller({
id: "member_1",
isActive: true,
@@ -911,11 +912,31 @@ describe("Participant edge cases", () => {
});
const res = await caller.member.checkStatus();
- expect(res.isMember).toBe(true);
+ // A row that outlived its paid year is not a membership. Answering true
+ // here is what greeted a lapsed member as active and hid the one button
+ // that would have let them renew.
+ expect(res.isMember).toBe(false);
expect(res.isActive).toBe(false);
+ expect(res.hasLapsed).toBe(true);
expect(res.daysRemaining).toBeLessThan(0);
});
+ it("does not report a revoked but unexpired membership as lapsed", async () => {
+ const caller = memberCaller({
+ id: "member_1",
+ isActive: false,
+ memberType: "new",
+ renewalCount: 0,
+ membershipEndDate: new Date(Date.now() + 30 * DAY),
+ });
+
+ const res = await caller.member.checkStatus();
+ expect(res.isActive).toBe(false);
+ // Switched off by staff while the term still runs — renewing is not the
+ // remedy, so the renew prompt stays down.
+ expect(res.hasLapsed).toBe(false);
+ });
+
// BUG: member.ts:435 `member.isActive && expiresAt && expiresAt > now`
// returns the literal null (not false) when membershipEndDate is null.
it("reports a membership with no end date as inactive, as a real boolean", async () => {
@@ -974,28 +995,38 @@ describe("Participant edge cases", () => {
// =====================================================================
describe("8. Membership writes", () => {
- // BUG: member.ts:98-134 writes the member row and its history row in two
- // unrelated statements — no db.transaction, unlike every other mutation.
- it("commits a new member and its audit row together", async () => {
+ /**
+ * `register` writes a PROFILE, not a membership. It used to stamp
+ * `membershipEndDate = now + 1 year` and let `isActive` default to true,
+ * which handed any signed-in caller a full paid-tier membership over tRPC
+ * for nothing. Only a completed payment may set a term, so there is also no
+ * "joined" history row to write and nothing to wrap in a transaction.
+ */
+ it("grants no membership term when a profile is created", async () => {
mockFindFirst.mockImplementation((table) => {
if (table === "hackathons") return { id: HACK_A };
return undefined;
});
- mockInsert.mockImplementation((_op, insertArgs) => {
- if (insertArgs[0] === membershipHistory)
- throw new Error("history insert failed");
- return [{ id: "member_1" }];
+ mockInsert.mockImplementation(() => [{ id: "member_1" }]);
+
+ await callerFor("user_a").member.register({
+ firstName: "Ada",
+ lastName: "Lovelace",
});
- await expect(
- callerFor("user_a").member.register({
- firstName: "Ada",
- lastName: "Lovelace",
- }),
- ).rejects.toThrow();
- // `db` is typed DrizzleDB | null (client.ts leaves it null without
- // DATABASE_URL); the vi.mock factory always supplies an object here.
- expect(db!.transaction).toHaveBeenCalled();
+ const memberInsert = mockInsert.mock.calls.find(
+ (call) => call[1]?.[0] === members,
+ );
+ expect(memberInsert).toBeDefined();
+ const values = memberInsert![2][0];
+ expect(values.isActive).toBe(false);
+ expect(values.membershipEndDate).toBeNull();
+
+ // Nothing was joined until a payment lands, so no audit row is written.
+ const historyInsert = mockInsert.mock.calls.find(
+ (call) => call[1]?.[0] === membershipHistory,
+ );
+ expect(historyInsert).toBeUndefined();
});
// BUG: nameSchema (member.ts:9-13) is /^[a-zA-Z\s'-]+$/, so any accented or
diff --git a/packages/api/src/.internal-tests/resilience.test.ts b/packages/api/src/.internal-tests/resilience.test.ts
index ec7c3e79..cd8676ac 100644
--- a/packages/api/src/.internal-tests/resilience.test.ts
+++ b/packages/api/src/.internal-tests/resilience.test.ts
@@ -166,32 +166,7 @@ describe("Resilience and Domain Edge Cases Verification Suite", () => {
});
});
- describe("5. Discord Grapheme Safe Channel Name Truncation", () => {
- it("should truncate channel names with multi-byte surrogate pairs safely", () => {
- // 4-byte unicode values (using unicode escapes for emojis)
- const compoundEmoji =
- "A\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66"; // family emoji
-
- const safeTruncateBytes = (str: string, maxBytes: number) => {
- const encoder = new TextEncoder();
- const decoder = new TextDecoder("utf-8");
- const bytes = encoder.encode(str);
- if (bytes.length <= maxBytes) return str;
-
- const sliced = bytes.slice(0, maxBytes);
- const decoded = decoder.decode(sliced);
- // Clean trailing corrupted surrogate halves
- return decoded.replace(/[\uFFFD\uD800-\uDBFF]$/, "");
- };
-
- const truncated = safeTruncateBytes(compoundEmoji, 5);
- expect(truncated.endsWith("\uFFFD")).toBe(false);
- const lastCode = truncated.charCodeAt(truncated.length - 1);
- expect(lastCode >= 0xd800 && lastCode <= 0xdbff).toBe(false);
- });
- });
-
- describe("6. Temporal and Calendar Rules", () => {
+ describe("5. Temporal and Calendar Rules", () => {
it("should calculate dates across leap year boundaries", () => {
// Leap day sign up
const leapDay = new Date("2024-02-29T12:00:00Z");
diff --git a/packages/api/src/.internal-tests/routers.test.ts b/packages/api/src/.internal-tests/routers.test.ts
index 8867983b..802c8384 100644
--- a/packages/api/src/.internal-tests/routers.test.ts
+++ b/packages/api/src/.internal-tests/routers.test.ts
@@ -1326,7 +1326,10 @@ describe("Router Integration and Access Control Verification Suite", () => {
expect(member.id).toBe("member_new_id");
expect(member.memberType).toBe("new");
- expect(mockInsert).toHaveBeenCalledTimes(2); // member + membershipHistory
+ // One write. `register` creates a profile, and only a completed payment
+ // grants a term — so there is no "joined" membershipHistory row to pair
+ // it with, and nothing to wrap in a transaction.
+ expect(mockInsert).toHaveBeenCalledTimes(1);
});
it("should reject duplicate member registration for the same hackathon", async () => {
diff --git a/packages/api/src/middleware/cache.ts b/packages/api/src/middleware/cache.ts
index 7c46fe0c..8f7f6b96 100644
--- a/packages/api/src/middleware/cache.ts
+++ b/packages/api/src/middleware/cache.ts
@@ -252,9 +252,9 @@ export const invalidatePortalContext = (userId: string) => {
};
/**
- * The role gate caches per hackathon for 60s and the sidebar reads the portal
- * context, so granting or revoking has to clear both or the new leader is shown
- * a tab the procedures still refuse.
+ * The role gate caches for 60s and the sidebar reads the portal context, so
+ * granting or revoking has to clear both or the new leader is shown a tab the
+ * procedures still refuse.
*/
export const clearProjectLeaderCaches = (userId: string) => {
cache.deletePattern(`${CacheKeys.projectLeader(userId)}*`);
diff --git a/packages/api/src/middleware/procedures.ts b/packages/api/src/middleware/procedures.ts
index 3b9341a0..7ba1282c 100644
--- a/packages/api/src/middleware/procedures.ts
+++ b/packages/api/src/middleware/procedures.ts
@@ -83,7 +83,13 @@ export const isSuperAdmin = isAdmin.use(async ({ ctx, next }) => {
});
/**
- * Verifies the caller runs initiatives for the current hackathon.
+ * Verifies the caller runs club initiatives.
+ *
+ * Not scoped to a hackathon: the club and the hackathon are separate aspects,
+ * and leading is a standing appointment rather than something re-granted every
+ * edition. It used to resolve the current edition first, which meant the gate
+ * refused every leader outright whenever no hackathon row existed — a club
+ * with no event scheduled had no project leaders at all.
*
* Admins pass without a project_leader row: staff cover for a leader who has
* gone quiet. The reverse is deliberately not true — this grants nothing under
@@ -95,15 +101,7 @@ export const isProjectLeader = protectedProcedure.use(async ({ ctx, next }) => {
const db = ctx.db as NonNullable;
const userId = ctx.userId as string;
- const hackathonId = await resolveHackathonId(db);
- if (!hackathonId) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "No hackathon context found",
- });
- }
-
- const cacheKey = `${CacheKeys.projectLeader(userId)}:${hackathonId}:role`;
+ const cacheKey = `${CacheKeys.projectLeader(userId)}:role`;
let leader = ctx.cache.get(cacheKey);
if (!leader) {
@@ -111,7 +109,6 @@ export const isProjectLeader = protectedProcedure.use(async ({ ctx, next }) => {
(await db.query.projectLeaders.findFirst({
where: and(
eq(projectLeaders.userId, userId),
- eq(projectLeaders.hackathonId, hackathonId),
eq(projectLeaders.isActive, true),
),
})) ?? null;
@@ -134,7 +131,6 @@ export const isProjectLeader = protectedProcedure.use(async ({ ctx, next }) => {
return next({
ctx: {
...ctx,
- hackathonId,
projectLeader: leader ?? null,
isPlatformAdmin,
},
diff --git a/packages/api/src/routers/hackathon/crud.ts b/packages/api/src/routers/hackathon/crud.ts
index f813ee05..432ae25b 100644
--- a/packages/api/src/routers/hackathon/crud.ts
+++ b/packages/api/src/routers/hackathon/crud.ts
@@ -23,6 +23,7 @@ export const hackathonCrudRouter = createTRPCRouter({
status: z
.enum([
"draft",
+ "announced",
"open",
"closed",
"in_progress",
@@ -203,9 +204,11 @@ export const hackathonCrudRouter = createTRPCRouter({
tracks: z.array(z.string().max(100)).max(50).optional(),
challenges: z.array(z.string().max(100)).max(50).optional(),
websiteUrl: z.string().url().max(500).optional(),
- // Draft keeps the hackathon invisible to participants; open lets them
- // register straight away without a second trip to the admin panel.
- status: z.enum(["draft", "open"]).default("draft"),
+ // Draft keeps the hackathon invisible to participants; announced puts
+ // its landing page and interest list live without opening
+ // registration; open lets them register straight away without a
+ // second trip to the admin panel.
+ status: z.enum(["draft", "announced", "open"]).default("draft"),
})
.refine((data) => data.endDate > data.startDate, {
message: "End date must be after start date",
@@ -258,6 +261,7 @@ export const hackathonCrudRouter = createTRPCRouter({
status: z
.enum([
"draft",
+ "announced",
"open",
"closed",
"in_progress",
diff --git a/packages/api/src/routers/hackathon/index.ts b/packages/api/src/routers/hackathon/index.ts
index 950d4e3e..e25f6af8 100644
--- a/packages/api/src/routers/hackathon/index.ts
+++ b/packages/api/src/routers/hackathon/index.ts
@@ -4,6 +4,7 @@ import { hackathonRegistrationRouter } from "./registration";
import { hackathonAdminRouter } from "./admin";
import { hackathonEventsRouter } from "./events";
import { hackathonContentRouter } from "./content";
+import { hackathonInterestRouter } from "./interest";
export const hackathonRouter = mergeRouters(
hackathonCrudRouter,
@@ -11,4 +12,5 @@ export const hackathonRouter = mergeRouters(
hackathonAdminRouter,
hackathonEventsRouter,
hackathonContentRouter,
+ hackathonInterestRouter,
);
diff --git a/packages/api/src/routers/hackathon/interest.ts b/packages/api/src/routers/hackathon/interest.ts
new file mode 100644
index 00000000..1c0ba62e
--- /dev/null
+++ b/packages/api/src/routers/hackathon/interest.ts
@@ -0,0 +1,184 @@
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { and, asc, desc, eq } from "drizzle-orm";
+import { hackathonInterest, hackathons, users } from "@query/db";
+import type { DrizzleDB } from "@query/db";
+import {
+ createTRPCRouter,
+ protectedProcedure,
+ publicProcedure,
+} from "../../trpc";
+import { isAdmin } from "../../middleware/procedures";
+
+/**
+ * The interest list for an edition that has been announced but is not yet
+ * taking registrations.
+ *
+ * Deliberately its own table rather than a `hackathon_participant` row with a
+ * new status: an interested person has agreed to nothing, and putting them in
+ * the participants table would have every count, export and capacity check
+ * treat them as a registration. Converting one into the other is a decision
+ * staff make when registration opens, not a status default.
+ */
+
+const interestInput = z.object({
+ hackathonId: z.string().uuid(),
+ school: z.string().trim().max(200).optional(),
+ // Free text, not a country enum. The hackathon is global and a dropdown that
+ // is missing somebody's country is a worse failure than an untidy string.
+ country: z.string().trim().max(100).optional(),
+ graduationYear: z.number().int().min(1900).max(2100).nullable().optional(),
+ experience: z.enum(["first", "one_or_two", "three_plus"]).optional(),
+});
+
+const blankToNull = (value: string | undefined) =>
+ value && value.length > 0 ? value : null;
+
+/**
+ * The edition the landing page is about: announced, not yet open. Soonest
+ * first, so announcing the year after next does not displace the one being
+ * promoted now.
+ */
+async function findAnnounced(db: DrizzleDB) {
+ return db.query.hackathons.findFirst({
+ where: and(
+ eq(hackathons.status, "announced"),
+ eq(hackathons.isPublic, true),
+ ),
+ orderBy: asc(hackathons.startDate),
+ });
+}
+
+export const hackathonInterestRouter = createTRPCRouter({
+ /**
+ * Public: the coming-soon page has to render for somebody who has never
+ * signed in — that visitor is the entire audience for it.
+ */
+ getUpcoming: publicProcedure.query(async ({ ctx }) => {
+ const db = ctx.db as DrizzleDB | null;
+ if (!db) return null;
+
+ const upcoming = await findAnnounced(db);
+ if (!upcoming) return null;
+
+ return {
+ id: upcoming.id,
+ name: upcoming.name,
+ description: upcoming.description,
+ location: upcoming.location,
+ startDate: upcoming.startDate,
+ endDate: upcoming.endDate,
+ theme: upcoming.theme,
+ websiteUrl: upcoming.websiteUrl,
+ };
+ }),
+
+ /** Whether the caller is already on the list, and what they told us. */
+ myInterest: protectedProcedure
+ .input(z.object({ hackathonId: z.string().uuid() }))
+ .query(async ({ ctx, input }) => {
+ const row = await (ctx.db as DrizzleDB).query.hackathonInterest.findFirst({
+ where: and(
+ eq(hackathonInterest.hackathonId, input.hackathonId),
+ eq(hackathonInterest.userId, ctx.userId),
+ ),
+ });
+ return row ?? null;
+ }),
+
+ /**
+ * Upserted, so submitting twice edits one entry rather than failing on the
+ * unique index or quietly creating a second. Somebody coming back to correct
+ * their graduation year should not have to find a delete button.
+ */
+ registerInterest: protectedProcedure
+ .input(interestInput)
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const target = await db.query.hackathons.findFirst({
+ where: eq(hackathons.id, input.hackathonId),
+ columns: { id: true, status: true, isPublic: true },
+ });
+
+ // A draft edition is not public, so it answers the way a made-up id does
+ // rather than confirming that staff are planning something.
+ if (!target || !target.isPublic || target.status === "draft") {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "That hackathon is not accepting interest.",
+ });
+ }
+
+ if (target.status !== "announced") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ target.status === "open"
+ ? "Registration is open — you can sign up properly now."
+ : "This hackathon is no longer collecting interest.",
+ });
+ }
+
+ const values = {
+ school: blankToNull(input.school),
+ country: blankToNull(input.country),
+ graduationYear: input.graduationYear ?? null,
+ experience: input.experience ?? null,
+ };
+
+ await db
+ .insert(hackathonInterest)
+ .values({
+ hackathonId: input.hackathonId,
+ userId: ctx.userId,
+ ...values,
+ })
+ .onConflictDoUpdate({
+ target: [hackathonInterest.hackathonId, hackathonInterest.userId],
+ set: { ...values, updatedAt: new Date() },
+ });
+
+ return { onList: true };
+ }),
+
+ /** Leaving the list. Idempotent, so a second click is not an error. */
+ withdrawInterest: protectedProcedure
+ .input(z.object({ hackathonId: z.string().uuid() }))
+ .mutation(async ({ ctx, input }) => {
+ await (ctx.db as DrizzleDB)
+ .delete(hackathonInterest)
+ .where(
+ and(
+ eq(hackathonInterest.hackathonId, input.hackathonId),
+ eq(hackathonInterest.userId, ctx.userId),
+ ),
+ );
+ return { onList: false };
+ }),
+
+ /**
+ * The list itself, for staff. Joined to `user` rather than storing a copy of
+ * the email, so a person who changes their address stays reachable.
+ */
+ listInterest: isAdmin
+ .input(z.object({ hackathonId: z.string().uuid() }))
+ .query(async ({ ctx, input }) => {
+ return (ctx.db as DrizzleDB)
+ .select({
+ userId: hackathonInterest.userId,
+ name: users.name,
+ email: users.email,
+ school: hackathonInterest.school,
+ country: hackathonInterest.country,
+ graduationYear: hackathonInterest.graduationYear,
+ experience: hackathonInterest.experience,
+ createdAt: hackathonInterest.createdAt,
+ })
+ .from(hackathonInterest)
+ .innerJoin(users, eq(users.id, hackathonInterest.userId))
+ .where(eq(hackathonInterest.hackathonId, input.hackathonId))
+ .orderBy(desc(hackathonInterest.createdAt))
+ .limit(5000);
+ }),
+});
diff --git a/packages/api/src/routers/initiative.ts b/packages/api/src/routers/initiative.ts
index f44993de..3d88f7b4 100644
--- a/packages/api/src/routers/initiative.ts
+++ b/packages/api/src/routers/initiative.ts
@@ -32,44 +32,61 @@ type Tx = Parameters[0]>[0];
/** The helpers below only read, so either handle will do. */
type Reader = DrizzleDB | Tx;
+/**
+ * A team is the leader plus the people they accept, so the stored cap — which
+ * counts accepted members only — is one less than this. Applied when a leader
+ * names no cap; an explicit null still means uncapped, for the initiatives that
+ * are a standing group rather than a team.
+ */
+const DEFAULT_TEAM_SIZE = 4;
+
const initiativeInput = z.object({
title: z.string().trim().min(1).max(200),
summary: z.string().trim().max(300).optional(),
description: z.string().trim().max(4000).optional(),
commitment: z.string().trim().max(120).optional(),
- maxMembers: z.number().int().positive().max(500).nullable().optional(),
+ maxMembers: z
+ .number()
+ .int()
+ .positive()
+ .max(500)
+ .nullable()
+ .optional()
+ .default(DEFAULT_TEAM_SIZE - 1),
});
/**
- * Admins manage every initiative; a leader manages only their own, and only in
- * the edition they currently lead. The role is granted per hackathon, so
- * matching on leaderUserId alone would let this year's leader reach the
- * initiative they ran last year — and its applicants' names, emails, and
- * pitches — long after that appointment lapsed. Callers turn a false into
- * NOT_FOUND rather than FORBIDDEN, so a leader who guesses another leader's id
- * does not learn from the error that it exists.
+ * Admins manage every initiative; a leader manages only their own. There is no
+ * edition to cross: an initiative belongs to whoever leads it and to nothing
+ * else. Callers turn a false into NOT_FOUND rather than FORBIDDEN, so a leader
+ * who guesses another leader's id does not learn from the error that it exists.
*/
function canManage(
- ctx: { userId: string; hackathonId: string; isPlatformAdmin: boolean },
+ ctx: { userId: string; isPlatformAdmin: boolean },
initiative: Initiative,
) {
- return (
- ctx.isPlatformAdmin ||
- (initiative.hackathonId === ctx.hackathonId &&
- initiative.leaderUserId === ctx.userId)
- );
+ return ctx.isPlatformAdmin || initiative.leaderUserId === ctx.userId;
}
-/** Applying is a member benefit, so it needs a membership that has not lapsed. */
-async function requireActiveMember(
- db: Reader,
- userId: string,
- hackathonId: string,
-) {
- const member = await db.query.members.findFirst({
- where: and(eq(members.userId, userId), eq(members.hackathonId, hackathonId)),
- columns: { isActive: true, membershipEndDate: true },
- });
+/**
+ * Applying is a member benefit, so it needs a membership that has not lapsed.
+ *
+ * Initiatives are unscoped but membership is not — a paid year still hangs off
+ * an edition, so this resolves the current one. No edition means nobody has a
+ * live membership to check, which refuses rather than waving everyone through.
+ */
+async function requireActiveMember(db: Reader, userId: string) {
+ const hackathonId = await resolveHackathonId(db as DrizzleDB);
+
+ const member = hackathonId
+ ? await db.query.members.findFirst({
+ where: and(
+ eq(members.userId, userId),
+ eq(members.hackathonId, hackathonId),
+ ),
+ columns: { isActive: true, membershipEndDate: true },
+ })
+ : undefined;
const active = !!(
member?.isActive &&
@@ -135,7 +152,6 @@ export const initiativeRouter = createTRPCRouter({
.innerJoin(users, eq(users.id, initiatives.leaderUserId))
.where(
and(
- eq(initiatives.hackathonId, ctx.hackathonId),
// Proposals and declines live in the member's own list and the admin
// review queue; this screen is for initiatives that actually exist.
inArray(initiatives.status, ["draft", "open", "closed"]),
@@ -243,7 +259,6 @@ export const initiativeRouter = createTRPCRouter({
const target = await db.query.projectLeaders.findFirst({
where: and(
eq(projectLeaders.userId, input.leaderUserId),
- eq(projectLeaders.hackathonId, ctx.hackathonId),
eq(projectLeaders.isActive, true),
),
columns: { id: true },
@@ -251,7 +266,7 @@ export const initiativeRouter = createTRPCRouter({
if (!target) {
throw new TRPCError({
code: "BAD_REQUEST",
- message: "That person is not a project leader for this edition.",
+ message: "That person is not a project leader.",
});
}
leaderUserId = input.leaderUserId;
@@ -268,7 +283,6 @@ export const initiativeRouter = createTRPCRouter({
const [created] = await db
.insert(initiatives)
.values({
- hackathonId: ctx.hackathonId,
leaderUserId,
title: input.title,
summary: input.summary ?? null,
@@ -465,132 +479,119 @@ export const initiativeRouter = createTRPCRouter({
* whether to join should be able to see what they would get. Applying is
* where the membership check bites.
*/
- list: protectedProcedure
- .input(z.object({ hackathonId: z.string().uuid().optional() }).optional())
- .query(async ({ ctx, input }) => {
- const db = ctx.db as DrizzleDB;
- const hackathonId = await resolveHackathonId(db, input?.hackathonId);
- if (!hackathonId) return [];
+ list: protectedProcedure.query(async ({ ctx }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const open = await db
+ .select({
+ id: initiatives.id,
+ title: initiatives.title,
+ summary: initiatives.summary,
+ description: initiatives.description,
+ commitment: initiatives.commitment,
+ status: initiatives.status,
+ maxMembers: initiatives.maxMembers,
+ archivedAt: initiatives.archivedAt,
+ leaderName: users.name,
+ leaderImage: users.image,
+ })
+ .from(initiatives)
+ .innerJoin(users, eq(users.id, initiatives.leaderUserId))
+ .where(
+ and(eq(initiatives.status, "open"), isNull(initiatives.archivedAt)),
+ )
+ .orderBy(asc(initiatives.title))
+ .limit(60);
- const open = await db
+ if (open.length === 0) return [];
+
+ const ids = open.map((row) => row.id);
+
+ const [seats, mine] = await Promise.all([
+ db
.select({
- id: initiatives.id,
- title: initiatives.title,
- summary: initiatives.summary,
- description: initiatives.description,
- commitment: initiatives.commitment,
- status: initiatives.status,
- maxMembers: initiatives.maxMembers,
- archivedAt: initiatives.archivedAt,
- leaderName: users.name,
- leaderImage: users.image,
+ initiativeId: initiativeApplications.initiativeId,
+ taken: count(),
})
- .from(initiatives)
- .innerJoin(users, eq(users.id, initiatives.leaderUserId))
+ .from(initiativeApplications)
.where(
and(
- eq(initiatives.hackathonId, hackathonId),
- eq(initiatives.status, "open"),
- isNull(initiatives.archivedAt),
+ inArray(initiativeApplications.initiativeId, ids),
+ eq(initiativeApplications.status, "accepted"),
),
)
- .orderBy(asc(initiatives.title))
- .limit(60);
-
- if (open.length === 0) return [];
-
- const ids = open.map((row) => row.id);
-
- const [seats, mine] = await Promise.all([
- db
- .select({
- initiativeId: initiativeApplications.initiativeId,
- taken: count(),
- })
- .from(initiativeApplications)
- .where(
- and(
- inArray(initiativeApplications.initiativeId, ids),
- eq(initiativeApplications.status, "accepted"),
- ),
- )
- .groupBy(initiativeApplications.initiativeId),
- db
- .select({
- initiativeId: initiativeApplications.initiativeId,
- status: initiativeApplications.status,
- })
- .from(initiativeApplications)
- .where(
- and(
- inArray(initiativeApplications.initiativeId, ids),
- eq(initiativeApplications.userId, ctx.userId),
- ),
- ),
- ]);
-
- const taken = new Map(seats.map((row) => [row.initiativeId, row.taken]));
- const status = new Map(mine.map((row) => [row.initiativeId, row.status]));
-
- return open.map((row) => {
- const accepted = taken.get(row.id) ?? 0;
- const myStatus = status.get(row.id) ?? null;
- return {
- ...row,
- accepted,
- // withdrawn reads as no application, because re-applying is allowed.
- myStatus: myStatus === "withdrawn" ? null : myStatus,
- isFull: row.maxMembers !== null && accepted >= row.maxMembers,
- };
- });
- }),
-
- myApplications: protectedProcedure
- .input(z.object({ hackathonId: z.string().uuid().optional() }).optional())
- .query(async ({ ctx, input }) => {
- const db = ctx.db as DrizzleDB;
- const hackathonId = await resolveHackathonId(db, input?.hackathonId);
- if (!hackathonId) return [];
-
- const rows = await db
+ .groupBy(initiativeApplications.initiativeId),
+ db
.select({
- id: initiatives.id,
- title: initiatives.title,
- summary: initiatives.summary,
- status: initiatives.status,
- maxMembers: initiatives.maxMembers,
- archivedAt: initiatives.archivedAt,
- leaderName: users.name,
- leaderEmail: users.email,
- myStatus: initiativeApplications.status,
- appliedAt: initiativeApplications.appliedAt,
- decidedAt: initiativeApplications.decidedAt,
+ initiativeId: initiativeApplications.initiativeId,
+ status: initiativeApplications.status,
})
.from(initiativeApplications)
- .innerJoin(
- initiatives,
- eq(initiatives.id, initiativeApplications.initiativeId),
- )
- .innerJoin(users, eq(users.id, initiatives.leaderUserId))
.where(
and(
+ inArray(initiativeApplications.initiativeId, ids),
eq(initiativeApplications.userId, ctx.userId),
- eq(initiatives.hackathonId, hackathonId),
- // A withdrawal is an exit, not a record to carry forever.
- ne(initiativeApplications.status, "withdrawn"),
),
- )
- .orderBy(desc(initiativeApplications.appliedAt))
- .limit(60);
+ ),
+ ]);
+
+ const taken = new Map(seats.map((row) => [row.initiativeId, row.taken]));
+ const status = new Map(mine.map((row) => [row.initiativeId, row.status]));
- // The leader's address is contact detail for people actually on the
- // initiative. Stripped here, not in the component — what the component
- // does not render still rides along in the payload.
- return rows.map(({ leaderEmail, ...row }) => ({
+ return open.map((row) => {
+ const accepted = taken.get(row.id) ?? 0;
+ const myStatus = status.get(row.id) ?? null;
+ return {
...row,
- leaderEmail: row.myStatus === "accepted" ? leaderEmail : null,
- }));
- }),
+ accepted,
+ // withdrawn reads as no application, because re-applying is allowed.
+ myStatus: myStatus === "withdrawn" ? null : myStatus,
+ isFull: row.maxMembers !== null && accepted >= row.maxMembers,
+ };
+ });
+ }),
+
+ myApplications: protectedProcedure.query(async ({ ctx }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const rows = await db
+ .select({
+ id: initiatives.id,
+ title: initiatives.title,
+ summary: initiatives.summary,
+ status: initiatives.status,
+ maxMembers: initiatives.maxMembers,
+ archivedAt: initiatives.archivedAt,
+ leaderName: users.name,
+ leaderEmail: users.email,
+ myStatus: initiativeApplications.status,
+ appliedAt: initiativeApplications.appliedAt,
+ decidedAt: initiativeApplications.decidedAt,
+ })
+ .from(initiativeApplications)
+ .innerJoin(
+ initiatives,
+ eq(initiatives.id, initiativeApplications.initiativeId),
+ )
+ .innerJoin(users, eq(users.id, initiatives.leaderUserId))
+ .where(
+ and(
+ eq(initiativeApplications.userId, ctx.userId),
+ // A withdrawal is an exit, not a record to carry forever.
+ ne(initiativeApplications.status, "withdrawn"),
+ ),
+ )
+ .orderBy(desc(initiativeApplications.appliedAt))
+ .limit(60);
+
+ // The leader's address is contact detail for people actually on the
+ // initiative. Stripped here, not in the component — what the component
+ // does not render still rides along in the payload.
+ return rows.map(({ leaderEmail, ...row }) => ({
+ ...row,
+ leaderEmail: row.myStatus === "accepted" ? leaderEmail : null,
+ }));
+ }),
/**
* Not `apply`: tRPC refuses a procedure named after anything on
@@ -633,7 +634,7 @@ export const initiativeRouter = createTRPCRouter({
throw notFound();
}
- await requireActiveMember(tx, userId, initiative.hackathonId);
+ await requireActiveMember(tx, userId);
if (initiative.status !== "open") {
throw new TRPCError({
@@ -749,15 +750,8 @@ export const initiativeRouter = createTRPCRouter({
.input(initiativeInput)
.mutation(async ({ ctx, input }) => {
const db = ctx.db as DrizzleDB;
- const hackathonId = await resolveHackathonId(db);
- if (!hackathonId) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "No hackathon context found",
- });
- }
- await requireActiveMember(db, ctx.userId, hackathonId);
+ await requireActiveMember(db, ctx.userId);
// A queue an admin has to read is a shared resource. Three open at once
// is plenty for one person and stops a single member flooding it.
@@ -766,7 +760,6 @@ export const initiativeRouter = createTRPCRouter({
.from(initiatives)
.where(
and(
- eq(initiatives.hackathonId, hackathonId),
eq(initiatives.leaderUserId, ctx.userId),
eq(initiatives.status, "proposed"),
),
@@ -783,7 +776,6 @@ export const initiativeRouter = createTRPCRouter({
const [created] = await db
.insert(initiatives)
.values({
- hackathonId,
leaderUserId: ctx.userId,
title: input.title,
summary: input.summary ?? null,
@@ -810,8 +802,6 @@ export const initiativeRouter = createTRPCRouter({
*/
myProposals: protectedProcedure.query(async ({ ctx }) => {
const db = ctx.db as DrizzleDB;
- const hackathonId = await resolveHackathonId(db);
- if (!hackathonId) return [];
return db
.select({
@@ -828,12 +818,7 @@ export const initiativeRouter = createTRPCRouter({
createdAt: initiatives.createdAt,
})
.from(initiatives)
- .where(
- and(
- eq(initiatives.hackathonId, hackathonId),
- eq(initiatives.leaderUserId, ctx.userId),
- ),
- )
+ .where(eq(initiatives.leaderUserId, ctx.userId))
.orderBy(desc(initiatives.createdAt))
.limit(40);
}),
@@ -866,8 +851,6 @@ export const initiativeRouter = createTRPCRouter({
/** The review queue. Oldest first — proposals are answered in order. */
listProposals: isAdmin.query(async ({ ctx }) => {
const db = ctx.db as DrizzleDB;
- const hackathonId = await resolveHackathonId(db);
- if (!hackathonId) return [];
return db
.select({
@@ -885,12 +868,7 @@ export const initiativeRouter = createTRPCRouter({
})
.from(initiatives)
.innerJoin(users, eq(users.id, initiatives.leaderUserId))
- .where(
- and(
- eq(initiatives.hackathonId, hackathonId),
- eq(initiatives.status, "proposed"),
- ),
- )
+ .where(eq(initiatives.status, "proposed"))
.orderBy(asc(initiatives.createdAt))
.limit(100);
}),
@@ -937,10 +915,7 @@ export const initiativeRouter = createTRPCRouter({
if (input.decision === "approve") {
const existing = await tx.query.projectLeaders.findFirst({
- where: and(
- eq(projectLeaders.userId, proposal.leaderUserId),
- eq(projectLeaders.hackathonId, proposal.hackathonId),
- ),
+ where: eq(projectLeaders.userId, proposal.leaderUserId),
});
if (existing) {
@@ -955,7 +930,6 @@ export const initiativeRouter = createTRPCRouter({
} else {
await tx.insert(projectLeaders).values({
userId: proposal.leaderUserId,
- hackathonId: proposal.hackathonId,
isActive: true,
appointedBy: ctx.userId,
});
@@ -975,8 +949,6 @@ export const initiativeRouter = createTRPCRouter({
listLeaders: isAdmin.query(async ({ ctx }) => {
const db = ctx.db as DrizzleDB;
- const hackathonId = await resolveHackathonId(db);
- if (!hackathonId) return [];
return db
.select({
@@ -990,26 +962,18 @@ export const initiativeRouter = createTRPCRouter({
})
.from(projectLeaders)
.innerJoin(users, eq(users.id, projectLeaders.userId))
- .where(eq(projectLeaders.hackathonId, hackathonId))
.orderBy(asc(users.email))
.limit(200);
}),
/**
- * Grant or revoke, by user id, for the current edition. Upserted rather than
- * deleted so an appointment stays on the record after it is revoked.
+ * Grant or revoke, by user id. Upserted rather than deleted so an
+ * appointment stays on the record after it is revoked.
*/
setLeader: isAdmin
.input(z.object({ userId: z.string(), isLeader: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const db = ctx.db as DrizzleDB;
- const hackathonId = await resolveHackathonId(db);
- if (!hackathonId) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "No hackathon context found",
- });
- }
const target = await db.query.users.findFirst({
where: eq(users.id, input.userId),
@@ -1020,10 +984,7 @@ export const initiativeRouter = createTRPCRouter({
}
const existing = await db.query.projectLeaders.findFirst({
- where: and(
- eq(projectLeaders.userId, input.userId),
- eq(projectLeaders.hackathonId, hackathonId),
- ),
+ where: eq(projectLeaders.userId, input.userId),
});
if (existing) {
@@ -1034,7 +995,6 @@ export const initiativeRouter = createTRPCRouter({
} else if (input.isLeader) {
await db.insert(projectLeaders).values({
userId: input.userId,
- hackathonId,
isActive: true,
appointedBy: ctx.userId,
});
diff --git a/packages/api/src/services/portal-context.ts b/packages/api/src/services/portal-context.ts
index 7434a22a..138ff3d6 100644
--- a/packages/api/src/services/portal-context.ts
+++ b/packages/api/src/services/portal-context.ts
@@ -104,7 +104,7 @@ export async function fetchPortalContext(
db: DrizzleDB,
userId: string,
): Promise {
- const [admin, hackathonId, judgeRecord] = await Promise.all([
+ const [admin, hackathonId, judgeRecord, leaderRecord] = await Promise.all([
db.query.admins.findFirst({
where: and(eq(admins.userId, userId), eq(admins.isActive, true)),
}),
@@ -113,33 +113,32 @@ export async function fetchPortalContext(
where: and(eq(judges.userId, userId), eq(judges.isActive, true)),
columns: { id: true, name: true },
}),
+ // Club side, so it does not wait on the edition and does not disappear
+ // between editions the way it used to.
+ db.query.projectLeaders.findFirst({
+ where: and(
+ eq(projectLeaders.userId, userId),
+ eq(projectLeaders.isActive, true),
+ ),
+ columns: { id: true },
+ }),
]);
let member = EMPTY_MEMBER_CONTEXT;
- let isProjectLeader = false;
- // Both are scoped to the edition, so neither can be read until it resolves.
+ // Membership is still scoped to the edition, so it waits for one to resolve.
if (hackathonId) {
- const [memberRecord, leaderRecord] = await Promise.all([
- db.query.members.findFirst({
- where: and(
- eq(members.userId, userId),
- eq(members.hackathonId, hackathonId),
- ),
- }),
- db.query.projectLeaders.findFirst({
- where: and(
- eq(projectLeaders.userId, userId),
- eq(projectLeaders.hackathonId, hackathonId),
- eq(projectLeaders.isActive, true),
- ),
- columns: { id: true },
- }),
- ]);
+ const memberRecord = await db.query.members.findFirst({
+ where: and(
+ eq(members.userId, userId),
+ eq(members.hackathonId, hackathonId),
+ ),
+ });
member = buildMemberContext(memberRecord ?? null);
- isProjectLeader = !!leaderRecord;
}
+ const isProjectLeader = !!leaderRecord;
+
return {
isAdmin: !!admin,
role: admin?.role ?? null,
diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts
index bd02daa2..9c21bc64 100644
--- a/packages/api/src/trpc.ts
+++ b/packages/api/src/trpc.ts
@@ -257,6 +257,10 @@ const CACHE_INVALIDATION_MAP: Record = {
"hackathon.createEvent": ["hackathon:*:events"],
"hackathon.updateEvent": ["hackathon:*:events"],
"hackathon.deleteEvent": ["hackathon:*:events"],
+ // Interest list. Both writes move the admin list and the caller's own
+ // "am I on it" answer, and the two are read from the same namespace.
+ "hackathon.registerInterest": ["hackathon:*:interest"],
+ "hackathon.withdrawInterest": ["hackathon:*:interest"],
// Judge mutations — only invalidate judging-related keys
"judge.submitVote": ["hackathon:*:rankings", "hackathon:*:judge-analytics"],
"judge.completeAndNext": [
diff --git a/packages/db/src/schemas/hackathons.ts b/packages/db/src/schemas/hackathons.ts
index cffe2abd..4ac85564 100644
--- a/packages/db/src/schemas/hackathons.ts
+++ b/packages/db/src/schemas/hackathons.ts
@@ -29,9 +29,16 @@ export const hackathons = pgTable(
hackingStartTime: timestamp("hacking_start_time"),
maxParticipants: integer("max_participants"),
currentParticipants: integer("current_participants").notNull().default(0),
+ /**
+ * `announced` is the gap between "nobody can see this" and "registration is
+ * open": the edition exists publicly, has a landing page and collects
+ * interest, but is not taking registrations and — importantly — is NOT the
+ * edition memberships attach to. See PRE_CURRENT_STATUSES below.
+ */
status: text("status", {
enum: [
"draft",
+ "announced",
"open",
"closed",
"in_progress",
@@ -240,13 +247,85 @@ export const hackathonProjects = pgTable(
],
);
+/**
+ * Editions that exist but are not yet "the current edition".
+ *
+ * `resolveCurrentHackathonId` skips these, which is what lets staff announce
+ * next year months ahead without every membership, portal gate and club
+ * check-in silently retargeting an edition nobody has registered for. An
+ * edition becomes current the moment it moves to `open`.
+ */
+export const PRE_CURRENT_STATUSES = ["draft", "announced"] as const;
+
+/**
+ * "Tell me when registration opens."
+ *
+ * Sign-in is required rather than taking a typed address: an entry is then a
+ * real `user` row with a verified email behind it, so the list can actually be
+ * mailed and an interested person converts into a participant without
+ * re-entering anything. Sign-in is not a Georgia Tech gate — the hackathon is
+ * open globally, and the email-code provider means anybody with any address can
+ * do it without a Google or GitHub account.
+ *
+ * The fields here are the ones that shape pre-event planning; everything else
+ * is asked at registration. `country` earns its place for a global field:
+ * travel, visa lead time and time zones for pre-event programming all depend on
+ * it, and it is far too late to ask once registration opens. All are optional —
+ * a blank answer should never be the reason somebody abandons the form.
+ */
+export const hackathonInterest = pgTable(
+ "hackathon_interest",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+ hackathonId: uuid("hackathon_id")
+ .notNull()
+ .references(() => hackathons.id, { onDelete: "cascade" }),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ school: text("school"),
+ country: text("country"),
+ graduationYear: integer("graduation_year"),
+ experience: text("experience", {
+ enum: ["first", "one_or_two", "three_plus"],
+ }),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+ },
+ (table) => [
+ index("hackathon_interest_hackathon_id_idx").on(table.hackathonId),
+ index("hackathon_interest_user_id_idx").on(table.userId),
+ // Registering interest twice is one person changing their answers, not two
+ // people. The unique index is what makes the upsert in `registerInterest`
+ // safe against a double submit.
+ unique("unique_interest_per_hackathon").on(table.hackathonId, table.userId),
+ ],
+);
+
+export type HackathonInterest = typeof hackathonInterest.$inferSelect;
+
// Relations
export const hackathonsRelations = relations(hackathons, ({ many }) => ({
participants: many(hackathonParticipants),
teams: many(hackathonTeams),
projects: many(hackathonProjects),
+ interest: many(hackathonInterest),
}));
+export const hackathonInterestRelations = relations(
+ hackathonInterest,
+ ({ one }) => ({
+ hackathon: one(hackathons, {
+ fields: [hackathonInterest.hackathonId],
+ references: [hackathons.id],
+ }),
+ user: one(users, {
+ fields: [hackathonInterest.userId],
+ references: [users.id],
+ }),
+ }),
+);
+
export const hackathonParticipantsRelations = relations(
hackathonParticipants,
({ one }) => ({
diff --git a/packages/db/src/schemas/initiatives.ts b/packages/db/src/schemas/initiatives.ts
index 5ec1b3d1..57bf89c3 100644
--- a/packages/db/src/schemas/initiatives.ts
+++ b/packages/db/src/schemas/initiatives.ts
@@ -10,20 +10,31 @@ import {
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { users } from "./auth";
-import { hackathons } from "./hackathons";
/**
* Club initiatives: things a project leader runs year-round that members apply
* to join. Named `initiative` rather than `project` because a hackathon
* "project" is already a judged submission, and one word for both would make
* every query and conversation ambiguous.
+ *
+ * Deliberately unscoped by hackathon. The club and the hackathon are two
+ * separate aspects of the platform: the hackathon has editions, registration,
+ * teams and judging; the club has initiatives that run whenever somebody is
+ * willing to lead one. Nothing here is ever judged — judges only ever score
+ * `hackathon_project`. Tying these tables to an edition, as they were, meant a
+ * club project silently belonged to whichever hackathon happened to be current
+ * on the day it was created, and vanished from every list the moment staff
+ * drafted the next one.
*/
/**
* The project-leader role, as its own assignment table rather than a value on
* `admin.role` — a leader is an elevated member, not staff, and nothing here
- * should widen an existing admin check. Scoped per hackathon edition, the same
- * way `judge` and `member` are.
+ * should widen an existing admin check.
+ *
+ * One row per person, not one per edition: leading is a standing appointment
+ * that lasts until somebody revokes it, so there is no yearly re-grant and
+ * nobody loses their initiatives when an edition rolls over.
*/
export const projectLeaders = pgTable(
"project_leader",
@@ -32,9 +43,6 @@ export const projectLeaders = pgTable(
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
- hackathonId: uuid("hackathon_id")
- .notNull()
- .references(() => hackathons.id, { onDelete: "cascade" }),
/** Revoked by clearing this, so the appointment stays on the record. */
isActive: boolean("is_active").notNull().default(true),
appointedBy: text("appointed_by").references(() => users.id, {
@@ -45,11 +53,7 @@ export const projectLeaders = pgTable(
},
(table) => [
index("project_leader_user_id_idx").on(table.userId),
- index("project_leader_hackathon_id_idx").on(table.hackathonId),
- unique("unique_project_leader_per_hackathon").on(
- table.userId,
- table.hackathonId,
- ),
+ unique("unique_project_leader").on(table.userId),
],
);
@@ -91,9 +95,6 @@ export const initiatives = pgTable(
"initiative",
{
id: uuid("id").defaultRandom().primaryKey(),
- hackathonId: uuid("hackathon_id")
- .notNull()
- .references(() => hackathons.id, { onDelete: "cascade" }),
leaderUserId: text("leader_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
@@ -104,7 +105,11 @@ export const initiatives = pgTable(
status: text("status", { enum: initiativeStatuses })
.notNull()
.default("draft"),
- /** Null means uncapped. Zero would be an initiative nobody can join. */
+ /**
+ * How many people the leader may accept, not counting themselves — a team
+ * of four is a leader plus three accepted members at `maxMembers = 3`.
+ * Null means uncapped. Zero would be an initiative nobody can join.
+ */
maxMembers: integer("max_members"),
archivedAt: timestamp("archived_at"),
/** Set when an admin approves or declines a proposal. */
@@ -118,7 +123,6 @@ export const initiatives = pgTable(
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
(table) => [
- index("initiative_hackathon_id_idx").on(table.hackathonId),
index("initiative_leader_idx").on(table.leaderUserId),
index("initiative_status_idx").on(table.status),
],
@@ -175,10 +179,6 @@ export type InitiativeApplication = typeof initiativeApplications.$inferSelect;
export const projectLeadersRelations = relations(projectLeaders, ({ one }) => ({
user: one(users, { fields: [projectLeaders.userId], references: [users.id] }),
- hackathon: one(hackathons, {
- fields: [projectLeaders.hackathonId],
- references: [hackathons.id],
- }),
}));
export const initiativesRelations = relations(initiatives, ({ one, many }) => ({
@@ -186,10 +186,6 @@ export const initiativesRelations = relations(initiatives, ({ one, many }) => ({
fields: [initiatives.leaderUserId],
references: [users.id],
}),
- hackathon: one(hackathons, {
- fields: [initiatives.hackathonId],
- references: [hackathons.id],
- }),
applications: many(initiativeApplications),
}));
diff --git a/packages/db/src/services/membership.test.ts b/packages/db/src/services/membership.test.ts
index 783a0762..4f8813ac 100644
--- a/packages/db/src/services/membership.test.ts
+++ b/packages/db/src/services/membership.test.ts
@@ -1,9 +1,59 @@
import { describe, it, expect, vi } from "vitest";
-import { createOrUpdateMembership, splitName } from "./membership";
+import {
+ createOrUpdateMembership,
+ resolveCurrentHackathonId,
+ splitName,
+} from "./membership";
import type { DrizzleDB } from "../client";
const DAY = 24 * 60 * 60 * 1000;
+/**
+ * A hackathons table that actually evaluates the `where` callback, so a test
+ * can tell a query that filters drafts from one that only says it does. The
+ * column references drizzle passes in are stood in for by their own names, and
+ * each operator returns a predicate over a plain row.
+ */
+function fakeHackathons(rows: Record[]) {
+ const columns = { status: "status", startDate: "startDate", endDate: "endDate" };
+
+ type Pred = (row: Record) => boolean;
+ const ops = {
+ and: (...preds: Pred[]): Pred => (row) => preds.every((p) => p(row)),
+ ne: (col: string, val: unknown): Pred => (row) => row[col] !== val,
+ notInArray: (col: string, vals: unknown[]): Pred => (row) =>
+ !vals.includes(row[col]),
+ lte: (col: string, val: Date): Pred => (row) => (row[col] as Date) <= val,
+ gte: (col: string, val: Date): Pred => (row) => (row[col] as Date) >= val,
+ desc: (col: string) => col,
+ };
+
+ return {
+ query: {
+ hackathons: {
+ findFirst: vi.fn(
+ async (args?: {
+ where?: (c: typeof columns, o: typeof ops) => Pred;
+ orderBy?: unknown;
+ }) => {
+ let matching = args?.where
+ ? rows.filter(args.where(columns, ops))
+ : [...rows];
+ if (args?.orderBy) {
+ matching = [...matching].sort(
+ (a, b) =>
+ (b.startDate as Date).getTime() -
+ (a.startDate as Date).getTime(),
+ );
+ }
+ return matching[0];
+ },
+ ),
+ },
+ },
+ } as unknown as DrizzleDB;
+}
+
/**
* A fake just wide enough for createOrUpdateMembership: one members row, and
* recorders for the insert/update it performs.
@@ -34,6 +84,80 @@ function fakeDb(existingMember: Record | undefined) {
return { db, updates, inserts };
}
+describe("resolveCurrentHackathonId", () => {
+ const running = {
+ id: "hack_running",
+ status: "open",
+ startDate: new Date(Date.now() - DAY),
+ endDate: new Date(Date.now() + DAY),
+ };
+ const lastYear = {
+ id: "hack_last_year",
+ status: "completed",
+ startDate: new Date(Date.now() - 300 * DAY),
+ endDate: new Date(Date.now() - 298 * DAY),
+ };
+ const nextYearDraft = {
+ id: "hack_next_draft",
+ status: "draft",
+ startDate: new Date(Date.now() + 300 * DAY),
+ endDate: new Date(Date.now() + 302 * DAY),
+ };
+ const nextYearAnnounced = {
+ ...nextYearDraft,
+ id: "hack_next_announced",
+ status: "announced",
+ };
+
+ it("prefers the edition actually running", async () => {
+ const db = fakeHackathons([lastYear, running, nextYearDraft]);
+ await expect(resolveCurrentHackathonId(db)).resolves.toBe("hack_running");
+ });
+
+ /**
+ * The one that mattered. The fallback ordered by start date with no filter,
+ * so the day staff drafted next year's edition it became "current" for the
+ * whole platform: every paying member read as lapsed, club check-in refused
+ * them, project leaders lost their portal tab, and Stripe grants landed
+ * against an edition nobody had announced.
+ */
+ it("falls back to the newest edition that is not a draft", async () => {
+ const db = fakeHackathons([lastYear, nextYearDraft]);
+ await expect(resolveCurrentHackathonId(db)).resolves.toBe("hack_last_year");
+ });
+
+ /**
+ * Announcing next year is a marketing act, not an administrative one. The
+ * landing page and the interest form go live months ahead; memberships,
+ * check-in and the portal gates must stay pointed at the edition people
+ * actually belong to until registration opens.
+ */
+ it("does not hand the current edition to one that is only announced", async () => {
+ const db = fakeHackathons([lastYear, nextYearAnnounced]);
+ await expect(resolveCurrentHackathonId(db)).resolves.toBe("hack_last_year");
+ });
+
+ it("hands it over once the announced edition opens", async () => {
+ const db = fakeHackathons([
+ lastYear,
+ { ...nextYearAnnounced, status: "open" },
+ ]);
+ await expect(resolveCurrentHackathonId(db)).resolves.toBe(
+ "hack_next_announced",
+ );
+ });
+
+ it("resolves nothing when every edition is a draft", async () => {
+ const db = fakeHackathons([nextYearDraft]);
+ await expect(resolveCurrentHackathonId(db)).resolves.toBeUndefined();
+ });
+
+ it("resolves nothing when there are no editions at all", async () => {
+ const db = fakeHackathons([]);
+ await expect(resolveCurrentHackathonId(db)).resolves.toBeUndefined();
+ });
+});
+
describe("splitName", () => {
/**
* A copy of this in the Stripe webhook lost a backslash and split on the
diff --git a/packages/db/src/services/membership.ts b/packages/db/src/services/membership.ts
index f56e30aa..b67b34bd 100644
--- a/packages/db/src/services/membership.ts
+++ b/packages/db/src/services/membership.ts
@@ -1,6 +1,7 @@
import { and, eq, isNull } from "drizzle-orm";
import type { DrizzleDB } from "../client";
import { members } from "../schemas/members";
+import { PRE_CURRENT_STATUSES } from "../schemas/hackathons";
import { stripePayments, userAccountLinks } from "../schemas/stripe";
/**
@@ -49,14 +50,27 @@ export async function resolveCurrentHackathonId(
const now = new Date();
const inProgress = await db.query.hackathons.findFirst({
- where: (h, { and: andFn, ne, lte, gte }) =>
- andFn(ne(h.status, "draft"), lte(h.startDate, now), gte(h.endDate, now)),
+ where: (h, { and: andFn, notInArray, lte, gte }) =>
+ andFn(
+ notInArray(h.status, [...PRE_CURRENT_STATUSES]),
+ lte(h.startDate, now),
+ gte(h.endDate, now),
+ ),
columns: { id: true },
});
const resolved =
inProgress ??
(await db.query.hackathons.findFirst({
+ // The status filter is the whole point of the comment above, and this
+ // branch is the one that needed it: the in-progress query can never match
+ // a future edition, so an unopened one could only ever arrive here.
+ // Without it, the day staff draft or announce next year's edition every
+ // membership read, portal gate and club check-in silently retargets an
+ // edition nobody has registered for, and every paying member reads as
+ // lapsed. An edition joins the running only when it opens.
+ where: (h, { notInArray }) =>
+ notInArray(h.status, [...PRE_CURRENT_STATUSES]),
orderBy: (h, { desc }) => [desc(h.startDate)],
columns: { id: true },
}));
diff --git a/sites/hacklytics2027/app/layout.tsx b/sites/hacklytics2027/app/layout.tsx
index f9150ad9..798f299a 100644
--- a/sites/hacklytics2027/app/layout.tsx
+++ b/sites/hacklytics2027/app/layout.tsx
@@ -4,6 +4,7 @@ import { Roboto_Mono, Space_Grotesk, Silkscreen } from "next/font/google";
import Navbar from "../components/Navbar";
import ServiceWorkerRegistrar from "../components/ServiceWorkerRegistrar";
import Footer from "../components/Footer";
+import { INTEREST_URL } from "../lib/links";
const robotoMono = Roboto_Mono({
subsets: ["latin"],
@@ -89,10 +90,14 @@ export default function RootLayout({ children }: { children: React.ReactNode })
description: "Data Science @ GT — The premier data science hackathon in the Southeast. 36 hours of coding, data science, and AI.",
offers: {
"@type": "Offer",
- url: "https://form.typeform.com/to/GvqBCdAe",
+ url: INTEREST_URL,
price: "0",
priceCurrency: "USD",
- availability: "https://schema.org/InStock",
+ // PreOrder, not InStock: registration has not opened, and the link behind
+ // this offer joins an interest list rather than securing a place. Search
+ // results that promise "register now" against a page that cannot are the
+ // kind of thing that gets rich results pulled.
+ availability: "https://schema.org/PreOrder",
validFrom: "2026-08-01T00:00:00-04:00"
},
organizer: {
diff --git a/sites/hacklytics2027/app/page.tsx b/sites/hacklytics2027/app/page.tsx
index 221c121a..6aca16b3 100644
--- a/sites/hacklytics2027/app/page.tsx
+++ b/sites/hacklytics2027/app/page.tsx
@@ -4,6 +4,7 @@ import HomeSections from "@/components/HomeSections";
import PixelGarden, { PixelGround } from "@/components/pixel/PixelGarden";
import PixelSprite from "@/components/pixel/PixelSprite";
import { BLOOM, DAISY, SPROUT, TULIP } from "@/components/pixel/sprites";
+import { INTEREST_URL } from "@/lib/links";
// ─── Elegant Floral Background ─────────────────────────────────────────────
const FloralBackground = () => (
@@ -173,13 +174,13 @@ export default function HomePage() {
{/* Framer-style CTA Buttons */}
- APPLY NOW
+ NOTIFY ME
diff --git a/sites/hacklytics2027/components/Navbar.tsx b/sites/hacklytics2027/components/Navbar.tsx
index 1b717bcb..47a73992 100644
--- a/sites/hacklytics2027/components/Navbar.tsx
+++ b/sites/hacklytics2027/components/Navbar.tsx
@@ -4,6 +4,7 @@ import Link from "next/link";
import Image from "next/image";
import PixelSprite from "./pixel/PixelSprite";
import { SPROUT } from "./pixel/sprites";
+import { INTEREST_URL } from "@/lib/links";
const navItems = [
{ name: "About", href: "/#about" },
@@ -105,12 +106,12 @@ export default function Navbar() {
{/* Desktop CTA */}
- APPLY
+ NOTIFY ME
{/* Mobile hamburger */}
@@ -151,13 +152,13 @@ export default function Navbar() {
diff --git a/sites/hacklytics2027/lib/links.ts b/sites/hacklytics2027/lib/links.ts
new file mode 100644
index 00000000..7ab58dac
--- /dev/null
+++ b/sites/hacklytics2027/lib/links.ts
@@ -0,0 +1,19 @@
+/**
+ * Outbound destinations, in one place.
+ *
+ * This site is a static export, so anything dynamic — the interest list, and
+ * later registration itself — lives on the portal and is reached by absolute
+ * URL. The Typeform this replaced was pasted into four separate files, which is
+ * how the homepage, both navbars and the JSON-LD offer all had to be found and
+ * edited by hand every time the destination moved.
+ */
+
+/** The portal origin. Matches BASE_URL / NEXTAUTH_URL in apphosting.yaml. */
+export const PORTAL_ORIGIN = "https://datasciencegt.org";
+
+/**
+ * The announced-edition landing page and interest form. Signing in is required
+ * to join the list, so the address behind it is verified — this link goes to
+ * the page that explains that, not straight into a login screen.
+ */
+export const INTEREST_URL = `${PORTAL_ORIGIN}/hacklytics`;
diff --git a/sites/mainweb/app/(portal)/admin/initiatives/page.tsx b/sites/mainweb/app/(portal)/admin/initiatives/page.tsx
index 4e9fc004..c904385b 100644
--- a/sites/mainweb/app/(portal)/admin/initiatives/page.tsx
+++ b/sites/mainweb/app/(portal)/admin/initiatives/page.tsx
@@ -9,7 +9,7 @@ import { trpc } from "@/lib/trpc";
import type { RouterOutputs } from "@query/api";
/**
- * Who runs initiatives this edition.
+ * Who runs club initiatives.
*
* Granting takes a user id rather than an email search: this reuses the
* attendees list every officer already works from, and a leader has to have
@@ -235,7 +235,7 @@ export default function AdminInitiativesPage() {
- Leaders this edition
+ Project leaders
{rows.length > 0 ? (
diff --git a/sites/mainweb/app/(portal)/api/cron/cleanup-audit-logs/route.ts b/sites/mainweb/app/(portal)/api/cron/cleanup-audit-logs/route.ts
index c2d407f7..b8d0fc6d 100644
--- a/sites/mainweb/app/(portal)/api/cron/cleanup-audit-logs/route.ts
+++ b/sites/mainweb/app/(portal)/api/cron/cleanup-audit-logs/route.ts
@@ -1,7 +1,19 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
+import { and, lt, ne } from "drizzle-orm";
import { db, auditLogs } from "@query/db";
+/**
+ * How long a security event is kept. This ran weekly as an unqualified
+ * `db.delete(auditLogs)` — a truncate, not a cleanup — so the effective
+ * retention for every injection attempt, auth failure and rate-limit trip was
+ * however long it had been since Monday. An investigation into anything older
+ * than that had nothing left to read.
+ */
+const RETAIN_DAYS = 90;
+/** Critical events outlive the routine window; they are the ones worth keeping. */
+const RETAIN_CRITICAL_DAYS = 365;
+
export async function GET(req: NextRequest) {
const auth = req.headers.get("authorization");
if (
@@ -13,6 +25,28 @@ export async function GET(req: NextRequest) {
if (!db)
return NextResponse.json({ error: "DB not initialized" }, { status: 500 });
- await db.delete(auditLogs);
- return NextResponse.json({ ok: true });
+
+ const cutoff = (days: number) =>
+ new Date(Date.now() - days * 24 * 60 * 60 * 1000);
+
+ // Both bound on created_at, which audit_created_at_idx covers.
+ const routine = await db
+ .delete(auditLogs)
+ .where(
+ and(
+ lt(auditLogs.createdAt, cutoff(RETAIN_DAYS)),
+ ne(auditLogs.severity, "critical"),
+ ),
+ )
+ .returning({ id: auditLogs.id });
+
+ const critical = await db
+ .delete(auditLogs)
+ .where(lt(auditLogs.createdAt, cutoff(RETAIN_CRITICAL_DAYS)))
+ .returning({ id: auditLogs.id });
+
+ return NextResponse.json({
+ ok: true,
+ deleted: routine.length + critical.length,
+ });
}
diff --git a/sites/mainweb/app/(portal)/dashboard/page.tsx b/sites/mainweb/app/(portal)/dashboard/page.tsx
index 758c98aa..e4e0c7c2 100644
--- a/sites/mainweb/app/(portal)/dashboard/page.tsx
+++ b/sites/mainweb/app/(portal)/dashboard/page.tsx
@@ -4,7 +4,7 @@ import { useSession, signOut } from "next-auth/react";
import { trpc } from "@/lib/trpc";
import { usePortalContext } from "@/lib/use-portal-context";
import { useRouter } from "next/navigation";
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import LinkStripeAccount from "@/components/portal/LinkStripeAccount";
@@ -22,6 +22,7 @@ import {
XCircle,
AlertCircle,
Gavel,
+ Rocket,
} from "lucide-react";
function StatusBadge({ status }: { status: string }) {
@@ -85,6 +86,17 @@ export default function Dashboard() {
const isAdmin = portalContext?.isAdmin ?? false;
const isJudge = portalContext?.isJudge ?? false;
+ /**
+ * Which half they land on. Held as null until they choose so the default can
+ * follow the data once it arrives — a paid member opens on Club, everyone
+ * else on Hackathon, which is the half that is open to them.
+ */
+ const [chosenView, setChosenView] = useState<"club" | "hackathon" | null>(
+ null,
+ );
+ const view = chosenView ?? (memberStatus?.isMember ? "club" : "hackathon");
+ const setView = setChosenView;
+
const now = new Date();
const activeRegs =
myRegs?.filter((r) =>
@@ -136,7 +148,7 @@ export default function Dashboard() {
- Membership required. Pay dues to unlock access.
+ {memberStatus?.hasLapsed
+ ? "Your membership has run out. Renew below to get back in."
+ : "Membership required. Join below to unlock access."}
+ ))}
+
+ {/* Initiatives — club side, but browsing is open so anyone can see
+ what membership actually buys before paying for it. */}
+ {view === "club" && (
+
+
+
+
+
+ Initiatives
+
+
+ Projects the club runs year-round. Join one, or pitch your
+ own.
+
+
+
+
)}
{/* Judge Portal — judges only */}
@@ -310,8 +348,40 @@ export default function Dashboard() {
)}
- {/* ── MEMBERSHIP CTA (non-members) ───────────────── */}
- {!memberStatus?.isMember && !isAdmin && (
+ {/* ── MEMBERSHIP ─────────────────────────────────── */}
+ {/* Club view only, and it now also catches lapsed members: `isMember`
+ means paid AND unexpired, so the renew path is reachable instead of
+ being hidden behind the same flag that expired. */}
+ {view === "club" && memberStatus?.isMember && (
+
+
+
+
+ Membership active
+
+
+ {memberStatus.expiresAt
+ ? `Runs until ${new Date(memberStatus.expiresAt).toLocaleDateString()}`
+ : "Active"}
+ {typeof memberStatus.daysRemaining === "number" &&
+ memberStatus.daysRemaining <= 30
+ ? ` · ${memberStatus.daysRemaining} days left`
+ : ""}
+
+
+ {/* Renewing early extends from the current end date rather than
+ from today, so nobody loses time by paying ahead. */}
+ {typeof memberStatus.daysRemaining === "number" &&
+ memberStatus.daysRemaining <= 30 && (
+
+
+
+ )}
+
+
+ )}
+
+ {view === "club" && !memberStatus?.isMember && !isAdmin && (
@@ -320,7 +390,9 @@ export default function Dashboard() {
- Become a Member
+ {memberStatus?.hasLapsed
+ ? "Renew your membership"
+ : "Become a Member"}
Join DSGT as a full member for{" "}
@@ -340,7 +412,7 @@ export default function Dashboard() {
)}
{/* ── MY HACKATHONS ───────────────────────────────── */}
-
+
My Hackathons
diff --git a/sites/mainweb/app/(portal)/hackathons/page.tsx b/sites/mainweb/app/(portal)/hackathons/page.tsx
index b67876f0..8fbce245 100644
--- a/sites/mainweb/app/(portal)/hackathons/page.tsx
+++ b/sites/mainweb/app/(portal)/hackathons/page.tsx
@@ -18,6 +18,7 @@ import {
} from "lucide-react";
type HackathonStatus =
+ | "announced"
| "open"
| "in_progress"
| "completed"
@@ -57,6 +58,14 @@ function statusConfig(s: HackathonStatus | "draft" | "cancelled") {
glow: string;
}
> = {
+ announced: {
+ label: "Opening Soon",
+ dot: "bg-cyan-400",
+ text: "text-cyan-400",
+ bg: "bg-cyan-400/10",
+ border: "border-cyan-400/30",
+ glow: "",
+ },
open: {
label: "Registering",
dot: "bg-emerald-400",
diff --git a/sites/mainweb/app/(portal)/hacklytics/page.tsx b/sites/mainweb/app/(portal)/hacklytics/page.tsx
new file mode 100644
index 00000000..894ad23b
--- /dev/null
+++ b/sites/mainweb/app/(portal)/hacklytics/page.tsx
@@ -0,0 +1,405 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { useSession } from "next-auth/react";
+import { trpc } from "@/lib/trpc";
+import { LoadingScreen } from "@/components/portal/LoadingScreen";
+
+/**
+ * The public landing page for an edition that has been announced but is not yet
+ * taking registrations, plus the interest form.
+ *
+ * Lives inside the (portal) route group so it inherits the tRPC and session
+ * providers, which are mounted only there — but it is NOT an authenticated
+ * page. A signed-out stranger is the entire audience, so everything above the
+ * form renders without a session and the sidebar is suppressed for it in
+ * PortalWrapper.
+ */
+
+const EXPERIENCE_OPTIONS = [
+ { value: "first", label: "This would be my first" },
+ { value: "one_or_two", label: "I've done one or two" },
+ { value: "three_plus", label: "I've done three or more" },
+] as const;
+
+type Experience = (typeof EXPERIENCE_OPTIONS)[number]["value"];
+
+/**
+ * Dates are rendered from a fixed locale and an explicit time zone rather than
+ * the viewer's. The event happens in Atlanta; showing somebody in Singapore
+ * their own local rendering of the start date is how a hackathon appears to
+ * begin on the wrong day.
+ */
+const formatRange = (start: Date, end: Date) => {
+ const opts: Intl.DateTimeFormatOptions = {
+ month: "long",
+ day: "numeric",
+ timeZone: "America/New_York",
+ };
+ const sameYear = start.getUTCFullYear() === end.getUTCFullYear();
+ const startText = start.toLocaleDateString("en-US", opts);
+ const endText = end.toLocaleDateString("en-US", {
+ ...opts,
+ year: "numeric",
+ });
+ return sameYear ? `${startText} – ${endText}` : `${startText} – ${endText}`;
+};
+
+function Field({
+ label,
+ hint,
+ children,
+}: {
+ label: string;
+ hint?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {label}
+
+ {children}
+ {hint ? (
+
+ {hint}
+
+ ) : null}
+
+ );
+}
+
+const inputClass =
+ "w-full px-4 py-3 bg-[var(--bg-primary)]/60 border border-[var(--border-subtle)] text-[var(--text-primary)] text-sm rounded-sm focus:border-accent/50 focus:outline-none focus:ring-2 focus:ring-accent/20 placeholder:text-[var(--text-muted)]/50 transition-all";
+
+export default function HacklyticsPage() {
+ const { data: session, status: sessionStatus } = useSession();
+ const utils = trpc.useUtils();
+
+ const upcoming = trpc.hackathon.getUpcoming.useQuery();
+ const hackathonId = upcoming.data?.id;
+
+ const mine = trpc.hackathon.myInterest.useQuery(
+ { hackathonId: hackathonId ?? "" },
+ { enabled: !!hackathonId && !!session },
+ );
+
+ const [school, setSchool] = useState("");
+ const [country, setCountry] = useState("");
+ const [graduationYear, setGraduationYear] = useState("");
+ const [experience, setExperience] = useState("");
+ const [editing, setEditing] = useState(false);
+ const [error, setError] = useState("");
+
+ // Prefill from an existing entry so "edit" starts from what they told us,
+ // rather than making them retype it to change one field.
+ useEffect(() => {
+ if (!mine.data) return;
+ setSchool(mine.data.school ?? "");
+ setCountry(mine.data.country ?? "");
+ setGraduationYear(
+ mine.data.graduationYear ? String(mine.data.graduationYear) : "",
+ );
+ setExperience((mine.data.experience as Experience) ?? "");
+ }, [mine.data]);
+
+ const refresh = async () => {
+ if (hackathonId) await utils.hackathon.myInterest.invalidate({ hackathonId });
+ };
+
+ const join = trpc.hackathon.registerInterest.useMutation({
+ onSuccess: async () => {
+ setError("");
+ setEditing(false);
+ await refresh();
+ },
+ onError: (e) => setError(e.message),
+ });
+
+ const leave = trpc.hackathon.withdrawInterest.useMutation({
+ onSuccess: async () => {
+ setError("");
+ setEditing(false);
+ await refresh();
+ },
+ onError: (e) => setError(e.message),
+ });
+
+ if (upcoming.isPending) return ;
+
+ if (upcoming.isError) {
+ return (
+
+
+
+ We could not load the next hackathon just now.
+
+
upcoming.refetch()}
+ className="mt-4 px-6 py-3 border border-[var(--border-subtle)] text-[var(--text-primary)] font-mono text-[10px] uppercase tracking-[0.2em] rounded-sm hover:bg-white/5 transition-all"
+ >
+ Try again
+
+
+
+ );
+ }
+
+ // Nothing announced. Said plainly rather than left as an empty page or a
+ // date invented to fill the space.
+ if (!upcoming.data) {
+ return (
+
+
+
+ Nothing announced yet
+
+
+ The next Hacklytics has not been announced. Follow Data Science @ GT
+ and it will show up here first.
+
+
+
+ );
+ }
+
+ const event = upcoming.data;
+ const onList = !!mine.data;
+ const showForm = !onList || editing;
+ const busy = join.isPending || leave.isPending;
+
+ return (
+
+
+
+
+
+ Registration opens soon
+
+
+
+
+ {event.name}
+
+
+
+
+
+ When
+
+
+ {formatRange(event.startDate, event.endDate)}
+
+
+ {event.location ? (
+
+
+ Where
+
+
+ {event.location}
+
+
+ ) : null}
+ {event.theme ? (
+
+
+ Theme
+
+ {event.theme}
+
+ ) : null}
+
+
+ {event.description ? (
+
+ {event.description}
+
+ ) : null}
+
+
+ {sessionStatus === "loading" ? (
+
Checking sign-in…
+ ) : !session ? (
+
+
+ Get told the moment it opens
+
+
+ Sign in so we have a verified address to reach you at. Google,
+ GitHub, or a code sent to any email — no account needed
+ beforehand, and it works wherever you are in the world.
+
+
+ Sign in to join the list
+
+
+ ) : (
+
+
+
+ {onList ? "You're on the list" : "Join the interest list"}
+
+
+ {onList
+ ? `We'll email ${session.user?.email} the moment registration opens.`
+ : "Four optional questions. They only shape how we plan the event — none of them affect whether you get in."}
+
+
+
+ {showForm ? (
+
{
+ e.preventDefault();
+ if (!hackathonId) return;
+ const parsedYear = graduationYear.trim()
+ ? Number(graduationYear)
+ : null;
+ if (
+ parsedYear !== null &&
+ (!Number.isInteger(parsedYear) ||
+ parsedYear < 1900 ||
+ parsedYear > 2100)
+ ) {
+ setError("That graduation year does not look right.");
+ return;
+ }
+ join.mutate({
+ hackathonId,
+ school: school.trim() || undefined,
+ country: country.trim() || undefined,
+ graduationYear: parsedYear,
+ experience: experience || undefined,
+ });
+ }}
+ >
+
+ setSchool(e.target.value)}
+ placeholder="Georgia Institute of Technology"
+ maxLength={200}
+ />
+
+
+
+ setCountry(e.target.value)}
+ placeholder="United States"
+ maxLength={100}
+ />
+
+
+
+ setGraduationYear(e.target.value)}
+ placeholder="2029"
+ inputMode="numeric"
+ />
+
+
+
+
+ setExperience(e.target.value as Experience | "")
+ }
+ >
+ Prefer not to say
+ {EXPERIENCE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+ {busy
+ ? "Saving…"
+ : onList
+ ? "Save changes"
+ : "Notify me when it opens"}
+
+ {onList ? (
+ {
+ setEditing(false);
+ setError("");
+ }}
+ disabled={busy}
+ className="px-6 py-4 border border-[var(--border-subtle)] text-[var(--text-primary)] font-mono text-[10px] uppercase tracking-[0.2em] rounded-sm hover:bg-white/5 transition-all disabled:opacity-40"
+ >
+ Cancel
+
+ ) : null}
+
+
+ ) : (
+
+ setEditing(true)}
+ className="px-6 py-4 border border-[var(--border-subtle)] text-[var(--text-primary)] font-mono text-[10px] uppercase tracking-[0.2em] rounded-sm hover:bg-white/5 transition-all"
+ >
+ Edit my answers
+
+
+ hackathonId && leave.mutate({ hackathonId })
+ }
+ disabled={busy}
+ className="px-6 py-4 text-[var(--text-muted)] font-mono text-[10px] uppercase tracking-[0.2em] rounded-sm hover:text-rose-400 transition-all disabled:opacity-40"
+ >
+ {leave.isPending ? "Leaving…" : "Take me off the list"}
+
+
+ )}
+
+ {error && !showForm ? (
+
{error}
+ ) : null}
+
+ )}
+
+
+ {event.websiteUrl ? (
+
+ More about {event.name} →
+
+ ) : null}
+
+
+ );
+}
diff --git a/sites/mainweb/app/(portal)/lead/page.tsx b/sites/mainweb/app/(portal)/lead/page.tsx
index 1481a5eb..a221f645 100644
--- a/sites/mainweb/app/(portal)/lead/page.tsx
+++ b/sites/mainweb/app/(portal)/lead/page.tsx
@@ -257,7 +257,7 @@ export default function LeadPage() {
{listing.error.data?.code === "FORBIDDEN"
- ? "You are not a project leader for this edition."
+ ? "You are not a project leader."
: listing.error.message}
{
if (session) {
const redirectTimeout = setTimeout(() => {
- if (portalContext?.isJudge && !portalContext?.isAdmin) {
+ // An explicit destination wins over the role default: somebody sent
+ // here by a page that asked them to sign in wants to land back on it,
+ // not on a dashboard that says nothing about why they signed in.
+ if (callbackUrl) {
+ router.push(callbackUrl);
+ } else if (portalContext?.isJudge && !portalContext?.isAdmin) {
router.push("/judge");
} else {
router.push("/dashboard");
@@ -35,7 +57,14 @@ export default function Home() {
return () => clearTimeout(redirectTimeout);
}
- }, [status, session, router, portalContext?.isJudge, portalContext?.isAdmin]);
+ }, [
+ status,
+ session,
+ router,
+ callbackUrl,
+ portalContext?.isJudge,
+ portalContext?.isAdmin,
+ ]);
const handleEmailLogin = async () => {
if (!email) return;
@@ -45,7 +74,7 @@ export default function Home() {
// send has to be read off the result or every failure looks like a send.
const res = await signIn("nodemailer", {
email,
- callbackUrl: "/dashboard",
+ callbackUrl: callbackUrl ?? "/dashboard",
redirect: false,
});
@@ -66,11 +95,11 @@ export default function Home() {
};
const handleSignIn = () => {
- signIn("google", { callbackUrl: "/dashboard" });
+ signIn("google", { callbackUrl: callbackUrl ?? "/dashboard" });
};
const handleGithubSignIn = () => {
- signIn("github", { callbackUrl: "/dashboard" });
+ signIn("github", { callbackUrl: callbackUrl ?? "/dashboard" });
};
if (!mounted)
diff --git a/sites/mainweb/app/(portal)/settings/page.tsx b/sites/mainweb/app/(portal)/settings/page.tsx
index 20c4fd48..d52a01cc 100644
--- a/sites/mainweb/app/(portal)/settings/page.tsx
+++ b/sites/mainweb/app/(portal)/settings/page.tsx
@@ -226,7 +226,7 @@ export default function SettingsPage() {
/>
-
-
- Auth Service
-
-
- Operational
-
-
- Discord Bot
+ Auth Service
Operational
diff --git a/sites/mainweb/components/Navbar/index.tsx b/sites/mainweb/components/Navbar/index.tsx
index c6379d3b..e0dc9bf6 100644
--- a/sites/mainweb/components/Navbar/index.tsx
+++ b/sites/mainweb/components/Navbar/index.tsx
@@ -38,16 +38,26 @@ export default function Navbar({
document.body.style.overflow = menuOpen ? "hidden" : "auto";
}, [menuOpen]);
+ /**
+ * `link: false` renders a react-scroll ScrollLink, so `to` has to be the id
+ * of an element on THIS page. Four entries carried a route path under
+ * `link: false`, which sent ScrollLink hunting for an element with id
+ * "/events" — it warns to the console and does nothing, so those four were
+ * dead clicks on the highest-traffic page in the site. Anything starting with
+ * a slash is a destination, not an anchor.
+ */
const homeMenuItems = [
{ name: "About", to: "about", link: false },
{ name: "Bootcamp", to: "bootcamp", link: false },
- { name: "Hacklytics", to: "/hackathons", link: false },
+ // The public announcement page, not /hackathons — that one is the
+ // signed-in participant's list and answers a stranger with a login screen.
+ { name: "Hacklytics", to: "/hacklytics", link: true },
{ name: "Projects", to: "projects", link: false },
{ name: "Get Involved", to: "getinvolved", link: false },
{ name: "Team", to: "/team", link: true },
- { name: "Events", to: "/events", link: false },
- { name: "History", to: "/history", link: false },
- { name: "Status", to: "/status", link: false },
+ { name: "Events", to: "/events", link: true },
+ { name: "History", to: "/history", link: true },
+ { name: "Status", to: "/status", link: true },
];
const otherPageMenuItems = [
diff --git a/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx b/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx
index 71882bd3..8d67ccf6 100644
--- a/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx
+++ b/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx
@@ -349,7 +349,7 @@ export function AttendeesTab({
("draft");
+ const [status, setStatus] = useState<"draft" | "announced" | "open">("draft");
const [error, setError] = useState("");
const createMutation = trpc.hackathon.create.useMutation({
@@ -145,15 +145,21 @@ export function CreateHackathonForm({
setStatus(e.target.value as "draft" | "open")}
+ onChange={(e) =>
+ setStatus(e.target.value as "draft" | "announced" | "open")
+ }
className="w-full px-4 py-3 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] text-sm font-mono focus:border-accent/50 focus:outline-none transition-colors"
>
Draft — hidden, nobody can register
+
+ Announced — public page and interest list, no registration
+
Open — registration live immediately
- Draft hackathons show as "Registration Closed" to
- participants until you open them.
+ Announced is the safe way to publish months ahead: /hacklytics
+ goes live and collects interest, but memberships and check-in keep
+ pointing at the current edition until you switch this to Open.
diff --git a/sites/mainweb/components/admin/hackathons/EditHackathonForm.tsx b/sites/mainweb/components/admin/hackathons/EditHackathonForm.tsx
index 25b2968a..dae4126f 100644
--- a/sites/mainweb/components/admin/hackathons/EditHackathonForm.tsx
+++ b/sites/mainweb/components/admin/hackathons/EditHackathonForm.tsx
@@ -7,6 +7,7 @@ import { toInputDate } from "@/components/admin/hackathons/constants";
const HACKATHON_STATUSES = [
"draft",
+ "announced",
"open",
"closed",
"in_progress",
diff --git a/sites/mainweb/components/admin/hackathons/JudgesTab.tsx b/sites/mainweb/components/admin/hackathons/JudgesTab.tsx
index 7ba76254..adbc0666 100644
--- a/sites/mainweb/components/admin/hackathons/JudgesTab.tsx
+++ b/sites/mainweb/components/admin/hackathons/JudgesTab.tsx
@@ -262,7 +262,7 @@ export function JudgesTab({ hackathonId }: { hackathonId: string }) {
>
= now` never held.
*/
-// Define cache control based on route pattern
+/**
+ * Everything behind authentication. Marking these `public` let shared caches
+ * store one member's rendered dashboard; `Vary: Cookie` is not a safe enough
+ * answer when the alternative costs nothing. `no-store` also stops the browser
+ * writing them to disk, which matters on the shared machines at check-in.
+ */
+const PRIVATE_PREFIXES = [
+ "/admin",
+ "/dashboard",
+ "/club",
+ "/hackathons",
+ "/initiatives",
+ "/judge",
+ "/lead",
+ "/settings",
+ "/submit",
+ "/verify",
+ "/login",
+];
+
function getCacheControl(pathname: string): string {
+ if (PRIVATE_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
+ return "private, no-store, must-revalidate";
+ }
if (pathname === "/") {
return "public, max-age=300, must-revalidate";
}
- if (pathname.startsWith("/admin") || pathname.startsWith("/dashboard")) {
- return "public, max-age=60, must-revalidate";
- }
+ // Assets under public/. Hashed build output never reaches here — the matcher
+ // below excludes _next/static.
if (
pathname.match(
/\.(js|css|ico|png|jpg|jpeg|gif|svg|webp|woff|woff2|ttf|eot)$/,
@@ -25,60 +55,21 @@ function getCacheControl(pathname: string): string {
) {
return "public, max-age=31536000, immutable";
}
- if (
- pathname.startsWith("/hackathons") ||
- pathname.startsWith("/events") ||
- pathname.startsWith("/projects")
- ) {
+ if (pathname.startsWith("/events") || pathname.startsWith("/projects")) {
return "public, max-age=3600, stale-while-revalidate=86400";
}
- if (pathname.startsWith("/club") || pathname.startsWith("/history")) {
+ if (pathname.startsWith("/history")) {
return "public, max-age=1800, stale-while-revalidate=7200";
}
return "no-cache, no-store, must-revalidate";
}
-// Define ETag - simplified version
-function getETag(pathname: string): string {
- const cacheKey = `${pathname}`;
- return `"${Buffer.from(cacheKey).toString("hex")}"`;
-}
-
-// Define Last-Modified
-function getLastModified(): string {
- return new Date().toUTCString();
-}
-
-// Handle ETag validation
-function handleETag(request: NextRequest): boolean {
- const ifNoneMatch = request.headers.get("If-None-Match");
- const etag = getETag(request.nextUrl.pathname);
-
- if (ifNoneMatch && ifNoneMatch === etag) {
- return true;
- }
- return false;
-}
-
-// Handle Last-Modified validation
-function handleLastModified(request: NextRequest): boolean {
- const ifModifiedSince = request.headers.get("If-Modified-Since");
- const lastModified = getLastModified();
-
- if (ifModifiedSince && new Date(ifModifiedSince) >= new Date(lastModified)) {
- return true;
- }
- return false;
-}
-
-// Security headers
const securityHeaders: string[] = [
"X-Content-Type-Options: nosniff",
"X-Frame-Options: DENY",
"X-XSS-Protection: 1; mode=block",
];
-// Static config for Next.js proxy
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|api/trpc|api/auth|api/webhooks).*)",
@@ -86,64 +77,15 @@ export const config = {
headers: true,
};
-// Export proxy function
export async function proxy(req: NextRequest): Promise {
- // Handle ETag validation - return 304 if unchanged
- if (handleETag(req)) {
- return new Response(null, {
- status: 304,
- headers: {
- "Cache-Control": "public, max-age=31536000, immutable",
- ETag: getETag(req.nextUrl.pathname),
- "Last-Modified": getLastModified(),
- Vary: "Accept-Encoding, Cookie, Authorization",
- ...securityHeaders.reduce(
- (acc, header) => {
- const [key, value] = header.split(": ");
- acc[key] = value;
- return acc;
- },
- {} as Record,
- ),
- },
- });
- }
-
- // Handle Last-Modified validation
- if (handleLastModified(req)) {
- return new Response(null, {
- status: 304,
- headers: {
- "Cache-Control": "public, max-age=31536000, immutable",
- "Last-Modified": getLastModified(),
- ETag: getETag(req.nextUrl.pathname),
- Vary: "Accept-Encoding, Cookie, Authorization",
- ...securityHeaders.reduce(
- (acc, header) => {
- const [key, value] = header.split(": ");
- acc[key] = value;
- return acc;
- },
- {} as Record,
- ),
- },
- });
- }
-
- // Add cache headers to response via NextResponse.next()
- const cacheControl = getCacheControl(req.nextUrl.pathname);
- const etag = getETag(req.nextUrl.pathname);
- const lastModified = getLastModified();
-
const response = NextResponse.next();
- // Set cache headers
- response.headers.set("Cache-Control", cacheControl);
- response.headers.set("ETag", etag);
- response.headers.set("Last-Modified", lastModified);
+ response.headers.set(
+ "Cache-Control",
+ getCacheControl(req.nextUrl.pathname),
+ );
response.headers.set("Vary", "Accept-Encoding, Cookie, Authorization");
- // Add security headers
securityHeaders.forEach((header) => {
const [key, value] = header.split(": ");
response.headers.set(key, value);
From cc61f5d1b7abcc683108ba5b488babc7d68b4280 Mon Sep 17 00:00:00 2001
From: Aamogh <120258212+aamoghS@users.noreply.github.com>
Date: Sat, 8 Aug 2026 19:13:00 -0400
Subject: [PATCH 04/10] Decouple club membership from the hackathon edition
(#316)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* adding
* feat(club): decouple membership from the hackathon edition
A club membership was welded to a hackathon edition: `member` was
`unique(userId, hackathonId)` NOT NULL and every read resolved through
`resolveCurrentHackathonId`. The day next year's edition flipped to `open`,
every paying member silently became a non-member — the portal showed the pay
button, club check-in threw at the door, initiatives locked out, and re-paying
discarded whatever months were left.
Membership is now annual and belongs to a person: `unique(userId)`, and the
edition clause is gone from every read site (registration, member, events,
initiative, portal-context, verify-email). `membership_history` is finally
written on join and renewal, because `hackathonId` had been doing accidental
duty as the only record of which year somebody was a member.
Also here:
- The Stripe payment row commits before any membership work. The two shared a
transaction, so a grant that threw rolled back the payment record as well —
the customer was charged and nothing anywhere recorded it, and none of the
recovery paths could help because they look for a row that was never written.
- Mock mode grants a real membership through the production confirm path, so
the club half can be developed without a Stripe key. The old test asserted a
returned shape, which is how "Access Granted with nothing written" survived.
- verify-email calls the shared membership service instead of its own third
copy, which restarted the term from today and wrote no history.
The database change is deliberately NOT applied by `drizzle-kit push` — push
offers to truncate when adding the unique constraint. Apply
packages/db/ddl/2026-08-08-membership-decouple.sql by hand; it back-fills
membership_history before dropping the column it replaces.
Verified: typecheck, 373 tests, lint --max-warnings 0, build.
* fix(stripe): recover a payment whose membership grant failed
Both points raised by Greptile on #316.
**A linked payment could never be retried.** The webhook records the payment
first and grants the membership after — deliberately, because sharing a
transaction meant a failed grant rolled the payment row back and lost the
charge entirely. But that ordering leaves a real state: payment row linked to
the user, no membership. Every recovery path skipped already-linked payments
(`if (existing.linkedUserId) continue;`), so that state was permanent — the
customer was charged, the payment was on file, and nothing ever retried.
reconcileMyPayments now treats "linked" as "not proof of a grant" and checks
membership_history instead. Every grant writes a history row, so a payment with
no history row at or after its own timestamp was never honoured. That also
distinguishes a failed grant from a membership that was granted a year ago and
has since lapsed, which must not be silently renewed off an old payment. Both
directions are tested, and the guard was mutation-tested.
**The DDL file overstated what it covers.** It handles only the change
drizzle-kit push cannot be trusted with (the unique constraint, where push
offers to truncate), but the same release also changes the judging tables. The
file now says so and gives the order: apply it, run migrate:push for the rest,
then push must report "No changes detected".
Verified: typecheck, 375 tests, lint --max-warnings 0, build.
---------
---
.../workflows/weekly-audit-log-cleanup.yml | 15 -
.gitignore | 1 +
README.md | 26 +-
apphosting.yaml | 13 +
package.json | 2 +-
.../hackathon-admin-edge.test.ts | 278 ++++++-
.../.internal-tests/hackathon-flow.test.ts | 11 +-
.../src/.internal-tests/judge-edge.test.ts | 339 ++++++--
.../.internal-tests/participant-edge.test.ts | 46 +-
.../src/.internal-tests/qr-checkin.test.ts | 6 +
.../api/src/.internal-tests/routers.test.ts | 43 +-
.../.internal-tests/stripe-payments.test.ts | 150 +++-
packages/api/src/middleware/audit.ts | 115 +++
packages/api/src/middleware/cache.ts | 5 +-
packages/api/src/middleware/db-errors.ts | 25 +
packages/api/src/middleware/procedures.ts | 48 +-
packages/api/src/middleware/security.ts | 24 +-
packages/api/src/routers/admin.ts | 19 +-
packages/api/src/routers/events.ts | 81 +-
packages/api/src/routers/hackathon/admin.ts | 656 +++++++++++----
.../api/src/routers/hackathon/announce.ts | 201 +++++
packages/api/src/routers/hackathon/content.ts | 262 +++++-
packages/api/src/routers/hackathon/crud.ts | 153 +++-
packages/api/src/routers/hackathon/events.ts | 100 ++-
packages/api/src/routers/hackathon/index.ts | 2 +
.../api/src/routers/hackathon/registration.ts | 8 +-
.../api/src/routers/hackathon/visibility.ts | 44 +
packages/api/src/routers/initiative.ts | 22 +-
packages/api/src/routers/judge/admin.ts | 465 +++++------
packages/api/src/routers/judge/portal.ts | 78 +-
packages/api/src/routers/judge/rankings.ts | 783 +++++++++++-------
packages/api/src/routers/member.ts | 108 +--
packages/api/src/routers/stripe.ts | 121 ++-
packages/api/src/routers/team.ts | 156 +++-
packages/api/src/services/portal-context.ts | 35 +-
packages/api/src/trpc.ts | 73 +-
packages/api/src/types/portal-context.ts | 14 +
packages/auth/src/config.ts | 21 +-
packages/auth/src/email.ts | 146 +++-
.../db/ddl/2026-08-08-membership-decouple.sql | 56 ++
packages/db/drizzle/meta/_journal.json | 6 +-
packages/db/src/client.ts | 29 +-
packages/db/src/schemas/admins.ts | 8 +-
packages/db/src/schemas/events.ts | 6 +
packages/db/src/schemas/hackathons.ts | 12 +
packages/db/src/schemas/judge.ts | 148 +++-
packages/db/src/schemas/members.ts | 19 +-
packages/db/src/services/membership.test.ts | 72 +-
packages/db/src/services/membership.ts | 69 +-
sites/hacklytics2027/lib/links.ts | 20 +-
.../app/(portal)/admin/analytics/page.tsx | 5 +-
.../admin/hackathons/[id]/attendees/page.tsx | 157 ----
.../(portal)/admin/hackathons/[id]/page.tsx | 19 +-
.../app/(portal)/admin/projects/page.tsx | 159 ++++
.../mainweb/app/(portal)/admin/setup/page.tsx | 239 +++---
.../(portal)/api/auth/verify-email/route.ts | 66 +-
.../api/cron/cleanup-audit-logs/route.ts | 52 --
.../app/(portal)/api/webhooks/stripe/route.ts | 132 +--
.../(portal)/hackathons/[id]/judge/page.tsx | 52 +-
.../app/(portal)/hackathons/[id]/page.tsx | 6 +-
.../hackathons/[id]/participants/page.tsx | 349 --------
sites/mainweb/app/(portal)/judge/page.tsx | 36 -
sites/mainweb/app/(portal)/login/page.tsx | 79 +-
sites/mainweb/app/(portal)/scan/page.tsx | 113 +++
sites/mainweb/app/(portal)/submit/page.tsx | 115 +++
sites/mainweb/app/(portal)/verify/page.tsx | 12 +-
sites/mainweb/app/docs/DocsPageClient.tsx | 22 +-
.../admin/hackathons/AnnouncementsTab.tsx | 284 +++++++
.../admin/hackathons/AttendeesTab.tsx | 436 ++++++++--
.../admin/hackathons/CreateHackathonForm.tsx | 88 +-
.../admin/hackathons/EditHackathonForm.tsx | 117 ++-
.../components/admin/hackathons/EventsTab.tsx | 73 +-
.../admin/hackathons/HackathonCard.tsx | 21 +-
.../components/admin/hackathons/JudgesTab.tsx | 131 +++
.../admin/hackathons/ScannerTab.tsx | 102 ++-
.../components/admin/setup/ImportDataStep.tsx | 254 ------
.../components/hackathon/ResultsTab.tsx | 124 +++
.../components/portal/LinkStripeAccount.tsx | 2 +
.../components/portal/PortalSidebar.tsx | 6 +
.../components/portal/StripePaymentModal.tsx | 42 +-
sites/mainweb/lib/safe-callback.test.ts | 31 +
sites/mainweb/lib/safe-callback.ts | 18 +
turbo.json | 7 +-
83 files changed, 6027 insertions(+), 2462 deletions(-)
delete mode 100644 .github/workflows/weekly-audit-log-cleanup.yml
create mode 100644 packages/api/src/middleware/audit.ts
create mode 100644 packages/api/src/middleware/db-errors.ts
create mode 100644 packages/api/src/routers/hackathon/announce.ts
create mode 100644 packages/api/src/routers/hackathon/visibility.ts
create mode 100644 packages/db/ddl/2026-08-08-membership-decouple.sql
delete mode 100644 sites/mainweb/app/(portal)/admin/hackathons/[id]/attendees/page.tsx
delete mode 100644 sites/mainweb/app/(portal)/api/cron/cleanup-audit-logs/route.ts
delete mode 100644 sites/mainweb/app/(portal)/hackathons/[id]/participants/page.tsx
create mode 100644 sites/mainweb/app/(portal)/scan/page.tsx
create mode 100644 sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx
delete mode 100644 sites/mainweb/components/admin/setup/ImportDataStep.tsx
create mode 100644 sites/mainweb/components/hackathon/ResultsTab.tsx
create mode 100644 sites/mainweb/lib/safe-callback.test.ts
create mode 100644 sites/mainweb/lib/safe-callback.ts
diff --git a/.github/workflows/weekly-audit-log-cleanup.yml b/.github/workflows/weekly-audit-log-cleanup.yml
deleted file mode 100644
index 029deba2..00000000
--- a/.github/workflows/weekly-audit-log-cleanup.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-name: Weekly Audit Log Cleanup
-
-on:
- schedule:
- - cron: "0 3 * * 1"
- workflow_dispatch:
-
-jobs:
- cleanup:
- runs-on: ubuntu-latest
- steps:
- - name: Clear audit_logs
- run: |
- curl -sf -H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \
- https://datasciencegt.org/api/cron/cleanup-audit-logs
diff --git a/.gitignore b/.gitignore
index 8754c83a..bcfb8f1e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -100,3 +100,4 @@ graphify-out/cost.json
# `*.tsbuildinfo` above does not match these, so they were tracked and every
# build dirtied the working tree.
.cache/
+bash.exe.stackdump
diff --git a/README.md b/README.md
index c3de9958..7fc619e6 100644
--- a/README.md
+++ b/README.md
@@ -43,7 +43,7 @@ in `drizzle.config.ts`.
| `members.ts` | `user_profile`, `member`, `membership_history` |
| `admins.ts` | `admin` |
| `hackathons.ts` | `hackathon`, `hackathon_team`, `hackathon_participant`, `hackathon_project`, `hackathon_event`, `hackathon_event_attendee` |
-| `judge.ts` | `judge`, `judge_assignment`, `judging_project`, `judge_vote`, `judge_queue`, `hackathon_map` |
+| `judge.ts` | `judge`, `judge_assignment`, `judging_project`, `judge_vote`, `judge_queue` |
| `initiatives.ts` | `project_leader`, `initiative`, `initiative_application` |
| `events.ts` | `event`, `event_check_in` |
| `stripe.ts` | `stripe_payment`, `user_account_link` |
@@ -78,14 +78,24 @@ Two aspects share the database and touch nowhere:
`member` is the one crossing case: a paid year still hangs off an edition, so
membership resolves the current hackathon even though initiatives do not.
-#### One-off step before the first push that carries this
+#### One-off step — only for a database that already has the edition-scoped tables
-`migrate:push` cannot work this one out on its own. `project_leader` moved from
-`unique(user_id, hackathon_id)` to `unique(user_id)`, so anybody appointed in
-more than one edition has more than one row; drizzle-kit fails building the new
-index partway and leaves the schema half-applied. Run this against the target
-database **once, before** the push. Every statement is guarded, so it is safe to
-re-run.
+**Check first:**
+
+```sql
+SELECT to_regclass('public.project_leader');
+```
+
+If that returns `NULL`, this database has never had the club tables. Skip
+everything below — `migrate:push` simply creates them in the current shape, and
+the statements here would error on tables that do not exist.
+
+If it returns a table name, `migrate:push` cannot work the change out on its
+own. `project_leader` moved from `unique(user_id, hackathon_id)` to
+`unique(user_id)`, so anybody appointed in more than one edition has more than
+one row; drizzle-kit fails building the new index partway and leaves the schema
+half-applied. Run this against that database **once, before** the push. Every
+statement is guarded, so it is safe to re-run.
```sql
BEGIN;
diff --git a/apphosting.yaml b/apphosting.yaml
index 1097a4b2..9d2fad38 100644
--- a/apphosting.yaml
+++ b/apphosting.yaml
@@ -51,3 +51,16 @@ env:
value: datascience.gt@gmail.com
- variable: CRON_SECRET
secret: CRON_SECRET
+ # Flood-protection thresholds, sized per instance for a full venue.
+ # These are the ceiling for one signed-in person, not for the building —
+ # the limiter keys on user id when somebody is signed in. The short block
+ # duration bounds a false positive to a page refresh rather than locking
+ # an attendee out for five minutes in the middle of a workshop.
+ - variable: DDOS_BURST_THRESHOLD
+ value: "3000"
+ - variable: DDOS_MAX_REQUESTS_PER_MINUTE
+ value: "20000"
+ - variable: DDOS_SUSPICIOUS_THRESHOLD
+ value: "14000"
+ - variable: DDOS_BLOCK_DURATION_MS
+ value: "30000"
diff --git a/package.json b/package.json
index ec042a06..bdcf132d 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
"lint": "turbo run lint",
"format": "prettier --write .",
"typecheck": "turbo run typecheck",
- "test": "vitest run packages/api packages/db"
+ "test": "vitest run packages/api packages/db sites/mainweb/lib"
},
"dependencies": {
"next": "16.3.0",
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 07a2aacc..1e22f039 100644
--- a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts
+++ b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts
@@ -56,7 +56,6 @@ vi.mock("@query/db", () => {
hackathonProjects: table("hackathonProjects"),
hackathonEvents: table("hackathonEvents"),
hackathonEventAttendees: table("hackathonEventAttendees"),
- hackathonMaps: table("hackathonMaps"),
members: table("members"),
events: table("events"),
eventCheckIns: table("eventCheckIns"),
@@ -162,7 +161,6 @@ vi.mock("@query/db", () => {
participantId: "participant_id",
checkedInAt: "checked_in_at",
},
- hackathonMaps: { _t: "hackathonMaps", id: "id", hackathonId: "hackathon_id" },
members: {
_t: "members",
id: "id",
@@ -290,6 +288,77 @@ describe("Hackathon admin management edge cases", () => {
return appRouter.createCaller(createMockCtx(ADMIN_USER));
};
+ // =====================================================================
+ describe("Volunteer scan tier", () => {
+ const volunteerCaller = (rows: Record = {}) =>
+ adminCaller(rows, "volunteer");
+
+ /**
+ * The whole point of the tier. A volunteer holds an admins row, so without
+ * an explicit role check they would pass every isAdmin gate in the API —
+ * including the one that deletes the hackathon and cascades every
+ * participant, team and vote with it.
+ */
+ it("refuses a volunteer every full-staff action", async () => {
+ const caller = volunteerCaller({
+ hackathons: { id: HACK_A, name: "Hacklytics 2027" },
+ });
+
+ await expect(
+ caller.hackathon.adminGetAttendees({ hackathonId: HACK_A }),
+ ).rejects.toThrow(/Admin access required/);
+
+ await expect(
+ caller.hackathon.exportAttendees({ hackathonId: HACK_A }),
+ ).rejects.toThrow(/Admin access required/);
+
+ await expect(
+ caller.hackathon.delete({
+ hackathonId: HACK_A,
+ confirmName: "Hacklytics 2027",
+ }),
+ ).rejects.toThrow(/Admin access required/);
+
+ await expect(
+ caller.hackathon.batchUpdateParticipantStatus({
+ hackathonId: HACK_A,
+ participantIds: [PART_A1],
+ status: "approved",
+ }),
+ ).rejects.toThrow(/Admin access required/);
+ });
+
+ it("lets a volunteer work a check-in desk", async () => {
+ const caller = volunteerCaller({
+ hackathonEvents: { id: EVENT_A, hackathonId: HACK_A },
+ });
+ mockFindMany.mockReturnValue([]);
+
+ await expect(
+ caller.hackathon.getEventAttendees({
+ hackathonId: HACK_A,
+ eventId: EVENT_A,
+ }),
+ ).resolves.toMatchObject({ matching: 0 });
+ });
+
+ // Full staff must keep the scan access they already had — the tier is
+ // additive at the desk, not a replacement for it.
+ it("still lets full staff scan", async () => {
+ const caller = adminCaller({
+ hackathonEvents: { id: EVENT_A, hackathonId: HACK_A },
+ });
+ mockFindMany.mockReturnValue([]);
+
+ await expect(
+ caller.hackathon.getEventAttendees({
+ hackathonId: HACK_A,
+ eventId: EVENT_A,
+ }),
+ ).resolves.toBeDefined();
+ });
+ });
+
const liveHackathon = (overrides: Record = {}) => ({
id: HACK_A,
name: "Hacklytics 2027",
@@ -333,7 +402,41 @@ describe("Hackathon admin management edge cases", () => {
// BUG: content.projects is a publicProcedure with no status filter, unlike
// its sibling getPublicProjects which exists precisely to hide drafts.
+ /**
+ * getById enforced the draft rule on the hackathon row, but its public
+ * children each queried by hackathonId with no such check — so anyone
+ * holding the uuid could read an unannounced edition's full schedule,
+ * gallery and results. NOT_FOUND rather than FORBIDDEN, because
+ * confirming a hidden edition exists is most of the leak.
+ */
+ it("hides a draft edition's schedule, gallery and results from the public", async () => {
+ mockFindFirst.mockImplementation((table: string) =>
+ table === "hackathons" ? { id: HACK_A, status: "draft" } : undefined,
+ );
+ mockFindMany.mockReturnValue([]);
+
+ const anon = publicCaller();
+
+ await expect(
+ anon.hackathon.getEvents({ hackathonId: HACK_A }),
+ ).rejects.toThrow(/not found/i);
+ await expect(
+ anon.hackathon.projects({ hackathonId: HACK_A }),
+ ).rejects.toThrow(/not found/i);
+ await expect(
+ anon.hackathon.getPublicProjects({ hackathonId: HACK_A }),
+ ).rejects.toThrow(/not found/i);
+ await expect(
+ anon.hackathon.getResults({ hackathonId: HACK_A }),
+ ).rejects.toThrow(/not found/i);
+ });
+
it("hides in-progress project drafts and their scores from rivals", async () => {
+ // The gallery now refuses to serve a hackathon the caller cannot see, so
+ // a visible one has to exist before the project filter is reached.
+ mockFindFirst.mockImplementation((table: string) =>
+ table === "hackathons" ? { id: HACK_A, status: "open" } : undefined,
+ );
mockFindMany.mockReturnValue([
{
id: PROJECT,
@@ -386,10 +489,10 @@ describe("Hackathon admin management edge cases", () => {
const mailed = mockSendAcceptanceEmail.mock.calls.map((c) => c[0].email);
expect(mailed).toEqual(["ada@example.com"]);
// The B participant's row is never updated, so it must not be counted.
- expect(res.count).toBe(1);
+ expect(res.approved).toBe(1);
});
- // BUG: `count` is `participantIds.length`, not the number of rows the
+ // BUG: `approved` is `participantIds.length`, not the number of rows the
// scoped UPDATE actually touched.
it("reports how many participants were really approved, not how many ids were pasted", async () => {
const caller = adminCaller({ hackathons: { name: "Hacklytics 2027" } });
@@ -404,7 +507,85 @@ describe("Hackathon admin management edge cases", () => {
participantIds: [PART_A1, PART_A2, PART_B1],
});
- expect(res.count).toBe(2);
+ expect(res.approved).toBe(2);
+ });
+
+ /**
+ * The recovery case. A mass send that died partway leaves everyone before
+ * the failure point already emailed; re-running is the obvious next move,
+ * and without reading the marker it congratulates them all again. An
+ * acceptance email cannot be unsent.
+ */
+ it("does not email anyone who already received their acceptance", async () => {
+ const caller = adminCaller({ hackathons: { name: "Hacklytics 2027" } });
+ mockFindMany.mockReturnValue([
+ {
+ id: PART_A1,
+ hackathonId: HACK_A,
+ acceptanceEmailSentAt: new Date("2026-08-01"),
+ user: { email: "ada@example.com" },
+ },
+ {
+ id: PART_A2,
+ hackathonId: HACK_A,
+ acceptanceEmailSentAt: null,
+ user: { email: "alan@example.com" },
+ },
+ ]);
+
+ const res = await caller.hackathon.sendMassAcceptanceEmails({
+ hackathonId: HACK_A,
+ participantIds: [PART_A1, PART_A2],
+ });
+
+ const mailed = mockSendAcceptanceEmail.mock.calls.map((c) => c[0].email);
+ expect(mailed).toEqual(["alan@example.com"]);
+ expect(res).toMatchObject({ emailed: 1, alreadyEmailed: 1 });
+ // Both are still approved — only the mail is skipped.
+ expect(res.approved).toBe(2);
+ });
+
+ // Deliberately resending is still possible; it just is not the default.
+ it("re-emails everyone when resend is asked for", async () => {
+ const caller = adminCaller({ hackathons: { name: "Hacklytics 2027" } });
+ mockFindMany.mockReturnValue([
+ {
+ id: PART_A1,
+ hackathonId: HACK_A,
+ acceptanceEmailSentAt: new Date("2026-08-01"),
+ user: { email: "ada@example.com" },
+ },
+ ]);
+
+ const res = await caller.hackathon.sendMassAcceptanceEmails({
+ hackathonId: HACK_A,
+ participantIds: [PART_A1],
+ resend: true,
+ });
+
+ expect(res.emailed).toBe(1);
+ });
+
+ // A send that the provider rejected must not be reported as delivered:
+ // "sent to 500" when 0 arrived gives the organiser no reason to look again.
+ it("counts emails that actually left, separately from approvals", async () => {
+ const caller = adminCaller({ hackathons: { name: "Hacklytics 2027" } });
+ mockFindMany.mockReturnValue([
+ { id: PART_A1, hackathonId: HACK_A, user: { email: "ada@example.com" } },
+ { id: PART_A2, hackathonId: HACK_A, user: { email: "alan@example.com" } },
+ ]);
+ mockSendAcceptanceEmail.mockRejectedValueOnce(
+ new Error("450 mailbox unavailable"),
+ );
+
+ const res = await caller.hackathon.sendMassAcceptanceEmails({
+ hackathonId: HACK_A,
+ participantIds: [PART_A1, PART_A2],
+ });
+
+ expect(res.approved).toBe(2);
+ expect(res.emailed).toBe(1);
+ expect(res.failedEmails).toEqual(["ada@example.com"]);
});
});
@@ -544,18 +725,101 @@ describe("Hackathon admin management edge cases", () => {
).resolves.toBeDefined();
});
+ /**
+ * `undefined` means leave alone, `null` means clear. Without the
+ * distinction a track list that was once set could never be emptied — the
+ * edit form would send `[]`, zod would drop it, and the stale value would
+ * keep routing judges at projects nobody entered for it.
+ */
+ it("clears a field sent as null and leaves omitted ones alone", async () => {
+ const caller = adminCaller({ hackathons: liveHackathon() });
+ mockUpdate.mockReturnValue([{ id: HACK_A }]);
+
+ await caller.hackathon.update({
+ id: HACK_A,
+ tracks: null,
+ rules: null,
+ });
+
+ const written = mockUpdate.mock.calls.at(-1)?.[2]?.[0];
+ expect(written).toMatchObject({ tracks: null, rules: null });
+ // theme was never sent, so it must not appear in the UPDATE at all.
+ expect(written).not.toHaveProperty("theme");
+ });
+
+ it("stores the tracks it was given", async () => {
+ const caller = adminCaller({ hackathons: liveHackathon() });
+ mockUpdate.mockReturnValue([{ id: HACK_A }]);
+
+ await caller.hackathon.update({
+ id: HACK_A,
+ tracks: ["AI", "Healthcare"],
+ });
+
+ expect(mockUpdate.mock.calls.at(-1)?.[2]?.[0]).toMatchObject({
+ tracks: ["AI", "Healthcare"],
+ });
+ });
+
// Every child table cascades off this row, so reporting success for an id
// that matched nothing hides a delete that never happened.
it("refuses to delete a hackathon id that does not exist", async () => {
- const caller = adminCaller({ hackathons: undefined });
+ // super_admin: deleting an edition is deliberately the narrowest gate
+ // in the product.
+ const caller = adminCaller({ hackathons: undefined }, "super_admin");
// RETURNING names the rows the statement itself removed; against an id
// that matches nothing that is the empty set.
mockDelete.mockReturnValue([]);
await expect(
- caller.hackathon.delete({ hackathonId: HACK_B }),
+ caller.hackathon.delete({
+ hackathonId: HACK_B,
+ confirmName: "Hacklytics 2027",
+ }),
).rejects.toThrow(/not found/i);
});
+
+ /**
+ * The audit trail must never be the reason an organiser's action fails.
+ * A delete that succeeded and went unrecorded is bad; a delete refused
+ * because the logging table was busy is worse, and from the outside it is
+ * indistinguishable from the guard doing its job.
+ */
+ it("still deletes when the audit write fails", async () => {
+ const caller = adminCaller(
+ { hackathons: { id: HACK_A, name: "Hacklytics 2027" } },
+ "super_admin",
+ );
+ mockDelete.mockReturnValue([{ id: HACK_A }]);
+ mockInsert.mockImplementation(() => {
+ throw new Error("audit_logs unavailable");
+ });
+
+ await expect(
+ caller.hackathon.delete({
+ hackathonId: HACK_A,
+ confirmName: "Hacklytics 2027",
+ }),
+ ).resolves.toMatchObject({ success: true });
+ });
+
+ // Eleven tables cascade off this row. A click-through confirm is one stray
+ // Enter key; the name has to be typed and has to match.
+ it("refuses to delete when the typed name does not match", async () => {
+ const caller = adminCaller(
+ { hackathons: { id: HACK_A, name: "Hacklytics 2027" } },
+ "super_admin",
+ );
+
+ await expect(
+ caller.hackathon.delete({
+ hackathonId: HACK_A,
+ confirmName: "hacklytics 2026",
+ }),
+ ).rejects.toThrow(/exact name/i);
+
+ expect(mockDelete).not.toHaveBeenCalled();
+ });
});
// =====================================================================
diff --git a/packages/api/src/.internal-tests/hackathon-flow.test.ts b/packages/api/src/.internal-tests/hackathon-flow.test.ts
index 03c653d1..9467217c 100644
--- a/packages/api/src/.internal-tests/hackathon-flow.test.ts
+++ b/packages/api/src/.internal-tests/hackathon-flow.test.ts
@@ -30,7 +30,6 @@ vi.mock("@query/db", () => {
hackathonProjects: table("hackathonProjects"),
hackathonEvents: table("hackathonEvents"),
hackathonEventAttendees: table("hackathonEventAttendees"),
- hackathonMaps: table("hackathonMaps"),
members: table("members"),
events: table("events"),
eventCheckIns: table("eventCheckIns"),
@@ -118,7 +117,6 @@ vi.mock("@query/db", () => {
eventId: "event_id",
participantId: "participant_id",
},
- hackathonMaps: { id: "id", hackathonId: "hackathon_id" },
members: { id: "id", userId: "user_id", hackathonId: "hackathon_id" },
membershipHistory: { id: "id", memberId: "member_id" },
events: {
@@ -441,8 +439,11 @@ describe("Hackathon end-to-end flow", () => {
).rejects.toThrow(/Event not found/);
});
- it("requires admin rights to scan a pass", async () => {
- mockFindFirst.mockImplementation(() => undefined); // not an admin
+ // Scanning is the one action volunteers may take, so it is gated on
+ // holding any active admins row rather than on being full staff. An
+ // ordinary participant still has none and is still refused.
+ it("requires event staff to scan a pass", async () => {
+ mockFindFirst.mockImplementation(() => undefined); // no admins row at all
const caller = appRouter.createCaller(createMockCtx("random_user"));
await expect(
@@ -451,7 +452,7 @@ describe("Hackathon end-to-end flow", () => {
eventId: EVENT_A,
participantId: PARTICIPANT,
}),
- ).rejects.toThrow(/Admin access required/);
+ ).rejects.toThrow(/Event staff access required/);
});
});
diff --git a/packages/api/src/.internal-tests/judge-edge.test.ts b/packages/api/src/.internal-tests/judge-edge.test.ts
index 88e4079d..8ebacbc6 100644
--- a/packages/api/src/.internal-tests/judge-edge.test.ts
+++ b/packages/api/src/.internal-tests/judge-edge.test.ts
@@ -45,6 +45,7 @@ vi.mock("@query/db", () => {
"orderBy",
"limit",
"offset",
+ "for",
]) {
chain[m] = (...a: any[]) => {
trace.push([m, a]);
@@ -67,7 +68,6 @@ vi.mock("@query/db", () => {
hackathonProjects: table("hackathonProjects"),
hackathonEvents: table("hackathonEvents"),
hackathonEventAttendees: table("hackathonEventAttendees"),
- hackathonMaps: table("hackathonMaps"),
members: table("members"),
events: table("events"),
eventCheckIns: table("eventCheckIns"),
@@ -76,6 +76,7 @@ vi.mock("@query/db", () => {
judgingProjects: table("judgingProjects"),
judgeVotes: table("judgeVotes"),
judgeQueue: table("judgeQueue"),
+ hackathonResults: table("hackathonResults"),
stripePayments: table("stripePayments"),
userAccountLinks: table("userAccountLinks"),
auditLogs: table("auditLogs"),
@@ -133,13 +134,17 @@ vi.mock("@query/db", () => {
registrationStatus: "registration_status",
},
hackathonTeams: { id: "id", hackathonId: "hackathon_id", name: "name" },
- hackathonProjects: { id: "id", hackathonId: "hackathon_id" },
+ hackathonProjects: {
+ id: "id",
+ hackathonId: "hackathon_id",
+ status: "status",
+ submittedAt: "submitted_at",
+ },
hackathonEvents: { id: "id", hackathonId: "hackathon_id", name: "name" },
hackathonEventAttendees: {
eventId: "event_id",
participantId: "participant_id",
},
- hackathonMaps: { id: "id", hackathonId: "hackathon_id", order: "order" },
members: { id: "id", userId: "user_id", hackathonId: "hackathon_id" },
membershipHistory: { id: "id", memberId: "member_id" },
events: {
@@ -168,6 +173,7 @@ vi.mock("@query/db", () => {
judgingProjects: {
id: "id",
hackathonId: "hackathon_id",
+ sourceProjectId: "source_project_id",
tableNumber: "table_number",
tracks: "tracks",
challenges: "challenges",
@@ -180,6 +186,14 @@ vi.mock("@query/db", () => {
score: "score",
durationSeconds: "duration_seconds",
},
+ hackathonResults: {
+ id: "id",
+ hackathonId: "hackathon_id",
+ projectId: "project_id",
+ track: "track",
+ placement: "placement",
+ publishedAt: "published_at",
+ },
judgeQueue: {
id: "id",
judgeId: "judge_id",
@@ -511,10 +525,21 @@ describe("Judge edge cases", () => {
// =====================================================================
describe("5. forceSkipOvertime reassignment", () => {
+ /**
+ * Candidate selection now runs two set-based queries rather than two per
+ * candidate: who already holds this project, and each judge's uncompleted
+ * count. The mocks mirror that shape — feeding the old per-candidate
+ * counts here would make these tests pass without exercising the sort.
+ */
const wireForceSkip = (opts: {
myAssignment?: Record;
others: Record[];
- remaining: number[];
+ /** judgeIds already holding the skipped project */
+ holders?: string[];
+ /** judgeId -> uncompleted queue length */
+ remaining?: Record;
+ /** the judge's own next uncompleted slot, if any */
+ next?: Record;
}) => {
const nextQueue = seq([
{ id: QUEUE_A, hackathonId: HACK_A }, // isJudge middleware lookup
@@ -525,7 +550,8 @@ describe("Judge edge cases", () => {
projectId: PROJECT_A,
project: { id: PROJECT_A, tracks: [] },
},
- // one "already queued?" lookup per candidate — all undefined
+ // the "what do I do next" lookup at the end
+ opts.next,
]);
mockFindFirst.mockImplementation((table: string) => {
if (table === "judges") return JUDGE_ROW;
@@ -540,9 +566,15 @@ describe("Judge edge cases", () => {
mockFindMany.mockImplementation((table: string) =>
table === "judgeAssignments" ? opts.others : [],
);
- for (const n of opts.remaining) {
- mockSelect.mockReturnValueOnce([{ count: n }]);
- }
+ mockSelect.mockReturnValueOnce(
+ (opts.holders ?? []).map((judgeId) => ({ judgeId })),
+ );
+ mockSelect.mockReturnValueOnce(
+ Object.entries(opts.remaining ?? {}).map(([judgeId, remaining]) => ({
+ judgeId,
+ remaining,
+ })),
+ );
};
// BUG: portal.ts:428-486 draws candidates from every judgeAssignments row
@@ -565,7 +597,7 @@ describe("Judge edge cases", () => {
judge: { id: "active_judge", isActive: true },
},
],
- remaining: [0, 4],
+ remaining: { inactive_judge: 0, active_judge: 4 },
});
await judgeCaller().judge.forceSkipOvertime({ queueId: QUEUE_A });
@@ -574,6 +606,61 @@ describe("Judge edge cases", () => {
expect(reassigned?.judgeId).toBe("active_judge");
});
+ // A judge already holding this project must not be handed it twice — they
+ // would see the same table appear again later in their own queue.
+ it("never hands the project to a judge who already has it", async () => {
+ wireForceSkip({
+ others: [
+ {
+ judgeId: "has_it",
+ track: null,
+ judge: { id: "has_it", isActive: true },
+ },
+ {
+ judgeId: "free_judge",
+ track: null,
+ judge: { id: "free_judge", isActive: true },
+ },
+ ],
+ holders: [JUDGE_ID, "has_it"],
+ remaining: { has_it: 0, free_judge: 9 },
+ });
+
+ await judgeCaller().judge.forceSkipOvertime({ queueId: QUEUE_A });
+
+ const reassigned = insertedRows().find(
+ (r: any) => r.projectId === PROJECT_A,
+ );
+ expect(reassigned?.judgeId).toBe("free_judge");
+ });
+
+ // Between two eligible judges the lighter queue wins, so the reassigned
+ // project is actually reached before judging closes.
+ it("prefers the judge with the fewest projects left", async () => {
+ wireForceSkip({
+ others: [
+ {
+ judgeId: "busy",
+ track: null,
+ judge: { id: "busy", isActive: true },
+ },
+ {
+ judgeId: "light",
+ track: null,
+ judge: { id: "light", isActive: true },
+ },
+ ],
+ remaining: { busy: 11, light: 2 },
+ });
+
+ await judgeCaller().judge.forceSkipOvertime({ queueId: QUEUE_A });
+
+ const reassigned = insertedRows().find(
+ (r: any) => r.projectId === PROJECT_A,
+ );
+ expect(reassigned?.judgeId).toBe("light");
+ });
+
// BUG: portal.ts:422-424 loads myAssignment with no hackathonId filter and
// then uses myAssignment.hackathonId (not queueItem.hackathonId) for the
// reassignment row, orphaning it in the wrong hackathon.
@@ -588,7 +675,7 @@ describe("Judge edge cases", () => {
judge: { id: "active_judge", isActive: true },
},
],
- remaining: [1],
+ remaining: { active_judge: 1 },
});
await judgeCaller().judge.forceSkipOvertime({ queueId: QUEUE_A });
@@ -597,11 +684,40 @@ describe("Judge edge cases", () => {
expect(reassigned?.hackathonId).toBe(HACK_A);
});
+ // Both siblings (completeAndNext, skipProject) stamp startedAt on the slot
+ // they hand over. Without it here the next table stays unclaimed and the
+ // following judge to ask for work is sent to the table this judge just
+ // walked up to.
+ it("claims the table it hands the judge next", async () => {
+ wireForceSkip({
+ others: [],
+ next: {
+ id: "queue_next",
+ judgeId: JUDGE_ID,
+ hackathonId: HACK_A,
+ projectId: "project_next",
+ project: { id: "project_next", tracks: [] },
+ },
+ });
+
+ const res = await judgeCaller().judge.forceSkipOvertime({
+ queueId: QUEUE_A,
+ });
+
+ expect(res.queueId).toBe("queue_next");
+ const claimed = mockUpdate.mock.calls.some(
+ (call: any) =>
+ call[2]?.[0]?.startedAt instanceof Date &&
+ !("isCompleted" in (call[2]?.[0] ?? {})),
+ );
+ expect(claimed).toBe(true);
+ });
+
// BUG: with no judgeAssignments row the whole reassignment block is
// skipped (portal.ts:426) yet the response still looks like a success, so
// the project is dropped with nobody left to judge it.
it("reports that nothing was reassigned when the judge has no assignment row", async () => {
- wireForceSkip({ myAssignment: undefined, others: [], remaining: [] });
+ wireForceSkip({ myAssignment: undefined, others: [] });
const res = await judgeCaller().judge.forceSkipOvertime({
queueId: QUEUE_A,
@@ -742,9 +858,16 @@ describe("Judge edge cases", () => {
// =====================================================================
describe("7. initializeQueue track filtering", () => {
- const wireInit = (track: string, projects: Record[]) => {
+ const wireInit = (
+ track: string,
+ projects: Record[],
+ judgeHackathonId: string = HACK_A,
+ ) => {
mockFindFirst.mockImplementation((table: string) => {
if (table === "admins") return ADMIN_ROW;
+ // The judge's own edition. initializeQueue reads this to refuse
+ // building a queue nobody could ever open.
+ if (table === "judges") return { hackathonId: judgeHackathonId };
if (table === "judgeAssignments")
return { judgeId: JUDGE_ID, hackathonId: HACK_A, track };
return undefined;
@@ -799,6 +922,26 @@ describe("Judge edge cases", () => {
expect(res.projectCount).toBe(1);
});
+
+ /**
+ * A judges row belongs to one hackathon and isJudge authorizes against it,
+ * so a queue built across editions can never be opened — the projects in
+ * it are simply never scored, with nothing anywhere reporting a problem.
+ * assignToHackathon already refuses this; this path did not.
+ */
+ it("refuses to build a queue for a judge from another hackathon", async () => {
+ wireInit("Sports", pool, HACK_B);
+
+ await expect(
+ adminCaller().judge.initializeQueue({
+ judgeId: JUDGE_ID,
+ hackathonId: HACK_A,
+ shuffle: false,
+ }),
+ ).rejects.toThrow(/different hackathon/i);
+
+ expect(mockDelete).not.toHaveBeenCalled();
+ });
});
// =====================================================================
@@ -858,58 +1001,96 @@ describe("Judge edge cases", () => {
});
// =====================================================================
- describe("9. Bulk import", () => {
- const wireExistingJudge = () => {
- mockFindFirst.mockImplementation((table: string) => {
- if (table === "admins") return ADMIN_ROW;
- if (table === "users") return { id: "u1", email: "ada@example.com" };
- if (table === "judges") return { id: JUDGE_ID, userId: "u1" };
- if (table === "judgeAssignments")
- return { judgeId: JUDGE_ID, hackathonId: HACK_A };
- return undefined;
- });
- };
+ describe("9. Promoting submissions into judging", () => {
+ const asAdmin = () =>
+ mockFindFirst.mockImplementation((table: string) =>
+ table === "admins" ? ADMIN_ROW : undefined,
+ );
- const importOne = () =>
- adminCaller().judge.bulkImportJudges({
+ const submission = (id: string, extra: Record = {}) => ({
+ id,
+ hackathonId: HACK_A,
+ name: `Project ${id}`,
+ description: "d",
+ tracks: ["AI"],
+ challenges: null,
+ isCreateX: false,
+ teamMembers: ["Ada", "Grace"],
+ githubUrl: null,
+ demoUrl: null,
+ team: null,
+ ...extra,
+ });
+
+ it("writes nothing when no project has been submitted", async () => {
+ asAdmin();
+ mockFindMany.mockReturnValue([]);
+
+ const res = await adminCaller().judge.promoteSubmissions({
hackathonId: HACK_A,
- judges: [{ name: "Ada", email: "ada@example.com" }],
});
- it("writes nothing when the judge, user and assignment already exist", async () => {
- wireExistingJudge();
+ expect(res).toMatchObject({ created: 0, total: 0 });
+ expect(mockInsert).not.toHaveBeenCalled();
+ });
+
+ // The whole point of the source link: an organiser presses this again as
+ // late submissions land, and must not get a second copy of every project
+ // with a fresh table number.
+ it("skips submissions that are already judgeable", async () => {
+ asAdmin();
+ mockFindMany.mockImplementation((table: string) => {
+ if (table === "hackathonProjects")
+ return [submission("s1"), submission("s2")];
+ if (table === "judgingProjects")
+ return [{ id: "jp1", sourceProjectId: "s1", tableNumber: 7 }];
+ return [];
+ });
+ mockSelect.mockResolvedValue([{ count: 0 }]);
+
+ const res = await adminCaller().judge.promoteSubmissions({
+ hackathonId: HACK_A,
+ });
- await importOne();
+ expect(res).toMatchObject({ created: 1, alreadyPresent: 1, total: 2 });
- expect(mockInsert).not.toHaveBeenCalled();
+ const rows = mockInsert.mock.calls[0]?.[2]?.[0];
+ expect(rows).toHaveLength(1);
+ expect(rows[0].sourceProjectId).toBe("s2");
+ // Numbering continues past the highest table already handed out.
+ expect(rows[0].tableNumber).toBe(8);
});
- // BUG: admin.ts:309 increments results.created for every row that did not
- // throw, including rows where nothing was created, so the admin is told
- // judges were imported when none were.
- it("counts only judges that were actually created", async () => {
- wireExistingJudge();
+ // hackathon_project.teamMembers is text[]; judging_project.teamMembers is
+ // a single text column. Assigning the array straight across puts
+ // "[object Object]" on a judge's screen.
+ it("flattens the team member array into the scalar column", async () => {
+ asAdmin();
+ mockFindMany.mockImplementation((table: string) =>
+ table === "hackathonProjects" ? [submission("s1")] : [],
+ );
+ mockSelect.mockResolvedValue([{ count: 0 }]);
- const res = await importOne();
+ await adminCaller().judge.promoteSubmissions({ hackathonId: HACK_A });
- expect(res.created).toBe(0);
+ const rows = mockInsert.mock.calls[0]?.[2]?.[0];
+ expect(rows[0].teamMembers).toBe("Ada, Grace");
});
- // BUG: admin.ts:366-369 calls .values(rows) unconditionally; an empty CSV
- // produces .values([]) which Drizzle rejects, turning a plausible admin
- // action into a 500.
- it("returns a zero-row result for an empty project import", async () => {
- mockFindFirst.mockImplementation((table: string) =>
- table === "admins" ? ADMIN_ROW : undefined,
+ // Queues are built from a snapshot of the project list. A project promoted
+ // afterwards is in nobody's queue and would never be judged, silently.
+ it("warns when queues already exist and new projects were added", async () => {
+ asAdmin();
+ mockFindMany.mockImplementation((table: string) =>
+ table === "hackathonProjects" ? [submission("s1")] : [],
);
+ mockSelect.mockResolvedValue([{ count: 12 }]);
- const res = await adminCaller().judge.bulkImportProjects({
+ const res = await adminCaller().judge.promoteSubmissions({
hackathonId: HACK_A,
- projects: [],
});
- expect(res.created).toBe(0);
- expect(mockInsert).not.toHaveBeenCalled();
+ expect(res.queuesNeedRebuild).toBe(true);
});
});
@@ -1155,4 +1336,66 @@ describe("Judge edge cases", () => {
expect(claimWrite).toBeDefined();
});
});
-});
+
+ // =====================================================================
+ describe("12. Freezing results", () => {
+ const wireResults = (opts: {
+ judgingActive?: boolean;
+ published?: Record;
+ }) => {
+ mockFindFirst.mockImplementation((table: string) => {
+ if (table === "admins") return ADMIN_ROW;
+ if (table === "hackathons")
+ return { id: HACK_A, judgingActive: opts.judgingActive ?? false };
+ if (table === "hackathonResults") return opts.published;
+ return undefined;
+ });
+ mockFindMany.mockReturnValue([]);
+ };
+
+ /**
+ * The z-score normalisation runs over the whole vote set, so one late vote
+ * shifts every project's score. A snapshot taken while judging is live is
+ * already stale by the time anyone reads it.
+ */
+ it("refuses to freeze results while judging is still live", async () => {
+ wireResults({ judgingActive: true });
+
+ await expect(
+ adminCaller().judge.computeResults({ hackathonId: HACK_A }),
+ ).rejects.toThrow(/still live/i);
+
+ expect(mockInsert).not.toHaveBeenCalled();
+ });
+
+ it("computes once judging has closed", async () => {
+ wireResults({ judgingActive: false });
+
+ const res = await adminCaller().judge.computeResults({
+ hackathonId: HACK_A,
+ });
+
+ // No projects wired, so nothing to place — but it got past the guard.
+ expect(res).toMatchObject({ computed: 0 });
+ });
+
+ // Recomputing under a published ordering would change placings people
+ // have already been told about, with no record that it happened.
+ it("refuses to recompute over published results", async () => {
+ wireResults({ judgingActive: false, published: { id: "r1" } });
+
+ await expect(
+ adminCaller().judge.computeResults({ hackathonId: HACK_A }),
+ ).rejects.toThrow(/already published/i);
+ });
+
+ it("refuses to publish when nothing has been computed", async () => {
+ wireResults({ judgingActive: false });
+ mockUpdate.mockReturnValue([]);
+
+ await expect(
+ adminCaller().judge.publishResults({ hackathonId: HACK_A }),
+ ).rejects.toThrow(/compute the results first/i);
+ });
+ });
+});
\ No newline at end of file
diff --git a/packages/api/src/.internal-tests/participant-edge.test.ts b/packages/api/src/.internal-tests/participant-edge.test.ts
index d71f3384..5da3cbaf 100644
--- a/packages/api/src/.internal-tests/participant-edge.test.ts
+++ b/packages/api/src/.internal-tests/participant-edge.test.ts
@@ -48,7 +48,6 @@ vi.mock("@query/db", async () => {
hackathonProjects: table("hackathonProjects"),
hackathonEvents: table("hackathonEvents"),
hackathonEventAttendees: table("hackathonEventAttendees"),
- hackathonMaps: table("hackathonMaps"),
members: table("members"),
membershipHistory: table("membershipHistory"),
events: table("events"),
@@ -159,7 +158,6 @@ vi.mock("@query/db", async () => {
eventId: "event_id",
participantId: "participant_id",
},
- hackathonMaps: { id: "id", hackathonId: "hackathon_id" },
members: {
id: "id",
userId: "user_id",
@@ -630,9 +628,6 @@ describe("Participant edge cases", () => {
return callerFor("user_a");
};
- // BUG: createTeam/joinTeam/submitProject only assert that a participant row
- // exists (team.ts:98, 170, 445) — registrationStatus is never inspected,
- // unlike hackathon.scanParticipantPass.
it.each(["rejected", "waitlisted"])(
"keeps a %s applicant out of teams and out of judging",
async (status) => {
@@ -953,30 +948,26 @@ describe("Participant edge cases", () => {
expect(res.daysRemaining).toBeNull();
});
- // BUG: getHackathonId (member.ts:20-27) resolves the default hackathon by
- // `orderBy desc(startDate)` with no status or date filter, so a future draft
- // hijacks every member lookup the moment staff create next year's event.
- it("resolves the hackathon in progress, not next year's draft", async () => {
- const catalogue = [
- { id: HACK_A, status: "open", startDate: new Date(Date.now() - DAY) },
- {
- id: HACK_NEXT,
- status: "draft",
- startDate: new Date(Date.now() + 300 * DAY),
- },
- ];
- mockFindFirst.mockImplementation((table, args) => {
+ /**
+ * A membership used to be keyed on (userId, hackathonId), so the day a new
+ * edition opened every read resolved to it, matched no row, and every
+ * paying member silently became a non-member. Membership status must not
+ * consult the hackathon table at all now.
+ */
+ it("reports a member as a member even with a newer edition open", async () => {
+ const hackathonReads: unknown[] = [];
+ mockFindFirst.mockImplementation((table) => {
if (table === "hackathons") {
- if (args?.orderBy)
- return [...catalogue].sort(
- (a, b) => b.startDate.getTime() - a.startDate.getTime(),
- )[0];
- return catalogue[0];
+ hackathonReads.push(table);
+ return {
+ id: HACK_NEXT,
+ status: "open",
+ startDate: new Date(Date.now() + 300 * DAY),
+ };
}
if (table === "members")
return {
id: "member_1",
- hackathonId: HACK_A,
isActive: true,
memberType: "continuous",
renewalCount: 1,
@@ -988,8 +979,10 @@ describe("Participant edge cases", () => {
const res = await callerFor("user_a").member.checkStatus();
expect(res.isMember).toBe(true);
- // The cache key records which hackathon the lookup actually targeted.
- expect(cache.get(`member:status:user_a:${HACK_A}`)).not.toBeNull();
+ expect(hackathonReads).toHaveLength(0);
+ // The cache key is keyed on the person alone — nothing evicts an
+ // edition-scoped key, which is how a stale "not a member" survived.
+ expect(cache.get(`member:status:user_a`)).not.toBeNull();
});
});
@@ -1165,4 +1158,5 @@ describe("Participant edge cases", () => {
await expect(caller.user.updateProfile({})).rejects.toThrow();
});
});
+
});
diff --git a/packages/api/src/.internal-tests/qr-checkin.test.ts b/packages/api/src/.internal-tests/qr-checkin.test.ts
index fd6a2f2d..3c07a890 100644
--- a/packages/api/src/.internal-tests/qr-checkin.test.ts
+++ b/packages/api/src/.internal-tests/qr-checkin.test.ts
@@ -732,6 +732,11 @@ describe("QR check-in", () => {
mockFindMany.mockImplementation((table: string) =>
table === "hackathonProjects" ? [{ ...project }] : [],
);
+ // The gallery refuses to serve a hackathon the caller cannot see, so a
+ // visible one has to exist before the column scrubbing is reached.
+ mockFindFirst.mockImplementation((table: string) =>
+ table === "hackathons" ? { id: HACK_A, status: "open" } : undefined,
+ );
const anon = appRouter.createCaller(createMockCtx());
const listed: any[] = await anon.hackathon.projects({
@@ -894,5 +899,6 @@ describe("QR check-in", () => {
expect(cache.deletePattern("events:list*")).toBe(2);
expect(cache.has("events:list:public")).toBe(false);
});
+
});
});
diff --git a/packages/api/src/.internal-tests/routers.test.ts b/packages/api/src/.internal-tests/routers.test.ts
index 802c8384..5af01b77 100644
--- a/packages/api/src/.internal-tests/routers.test.ts
+++ b/packages/api/src/.internal-tests/routers.test.ts
@@ -106,10 +106,6 @@ vi.mock("@query/db", () => {
findFirst: (...args: any[]) => mockFindFirst("judgeQueue", ...args),
findMany: (...args: any[]) => mockFindMany("judgeQueue", ...args),
},
- hackathonMaps: {
- findFirst: (...args: any[]) => mockFindFirst("hackathonMaps", ...args),
- findMany: (...args: any[]) => mockFindMany("hackathonMaps", ...args),
- },
stripePayments: {
findFirst: (...args: any[]) =>
mockFindFirst("stripePayments", ...args),
@@ -271,10 +267,6 @@ vi.mock("@query/db", () => {
hackathonId: "hackathon_id",
isCompleted: "is_completed",
},
- hackathonMaps: {
- id: "id",
- hackathonId: "hackathon_id",
- },
stripePayments: {
id: "id",
customerEmail: "customer_email",
@@ -652,8 +644,9 @@ describe("Router Integration and Access Control Verification Suite", () => {
});
it("should ensure backslash escapes in sql queries are checked securely", () => {
- // Drizzle handles parameterization automatically, so raw inputs are never interpolated directly.
- // We test that inputs containing backslashes are sanitized/passed as single literals.
+ // Drizzle handles parameterization automatically, so raw inputs are never
+ // interpolated directly. We test that inputs containing backslashes are
+ // sanitized/passed as single literals.
const dangerousValue = "value\\' OR \\'1\\'=\\'1";
const cleanValue = sanitizeInput(dangerousValue);
expect(typeof cleanValue).toBe("string");
@@ -1085,17 +1078,31 @@ describe("Router Integration and Access Control Verification Suite", () => {
expect(updated.status).toBe("open");
});
- it("should allow admin to delete a hackathon", async () => {
+ it("should allow a super admin to delete a hackathon", async () => {
const ctx = createMockCtx("admin_user_id");
mockFindFirst.mockImplementation((table) => {
if (table === "admins") {
- return { id: "admin_1", userId: "admin_user_id", role: "admin", isActive: true };
+ // Deleting an edition is super-admin only: isAdmin never checked
+ // role, so the default "admin" could destroy every participant,
+ // team, project and vote attached to it.
+ return {
+ id: "admin_1",
+ userId: "admin_user_id",
+ role: "super_admin",
+ isActive: true,
+ };
+ }
+ if (table === "hackathons") {
+ return { id: hackathonId, name: "Test Hackathon" };
}
return null;
});
const caller = appRouter.createCaller(ctx);
- const res = await caller.hackathon.delete({ hackathonId });
+ const res = await caller.hackathon.delete({
+ hackathonId,
+ confirmName: "Test Hackathon",
+ });
expect(res.success).toBe(true);
expect(mockDelete).toHaveBeenCalled();
});
@@ -1291,7 +1298,7 @@ describe("Router Integration and Access Control Verification Suite", () => {
describe("11. Member Registration, Renewal, and Status Tracking", () => {
const hackathonId = "00000000-0000-0000-0000-000000000040";
- it("should register a user as a member for a hackathon", async () => {
+ it("should register a user as a member", async () => {
const ctx = createMockCtx("user_member_1");
mockFindFirst.mockImplementation((table) => {
@@ -1318,7 +1325,6 @@ describe("Router Integration and Access Control Verification Suite", () => {
const caller = appRouter.createCaller(ctx);
const member = await caller.member.register({
- hackathonId,
firstName: "John",
lastName: "Doe",
phoneNumber: "+14045550123",
@@ -1332,7 +1338,7 @@ describe("Router Integration and Access Control Verification Suite", () => {
expect(mockInsert).toHaveBeenCalledTimes(1);
});
- it("should reject duplicate member registration for the same hackathon", async () => {
+ it("should reject duplicate member registration", async () => {
const ctx = createMockCtx("user_member_1");
mockFindFirst.mockImplementation((table) => {
@@ -1344,11 +1350,10 @@ describe("Router Integration and Access Control Verification Suite", () => {
const caller = appRouter.createCaller(ctx);
await expect(
caller.member.register({
- hackathonId,
firstName: "John",
lastName: "Doe",
}),
- ).rejects.toThrowError("You are already a member for this hackathon");
+ ).rejects.toThrowError("You already have a member profile");
});
it("should return correct membership status and days remaining", async () => {
@@ -1372,7 +1377,7 @@ describe("Router Integration and Access Control Verification Suite", () => {
});
const caller = appRouter.createCaller(ctx);
- const status = await caller.member.checkStatus({ hackathonId });
+ const status = await caller.member.checkStatus();
expect(status.isMember).toBe(true);
expect(status.isActive).toBe(true);
diff --git a/packages/api/src/.internal-tests/stripe-payments.test.ts b/packages/api/src/.internal-tests/stripe-payments.test.ts
index 395d5cfb..b18c26bb 100644
--- a/packages/api/src/.internal-tests/stripe-payments.test.ts
+++ b/packages/api/src/.internal-tests/stripe-payments.test.ts
@@ -20,6 +20,37 @@ import { MEMBERSHIP_CENTS, BOOTCAMP_ADDON_CENTS } from "../services/pricing";
const mockFindFirst = vi.fn();
const mockInsert = vi.fn();
+/**
+ * The Stripe SDK is stubbed so no test reaches the network.
+ *
+ * Without this, "refuses a mock intent id when not in mock mode" set a fake
+ * secret key and then genuinely called api.stripe.com — the request spent ~23
+ * seconds on SDK retries and failed the whole suite whenever the machine was
+ * offline or slow, for reasons that had nothing to do with the assertion.
+ */
+/** Payment intents `reconcileMyPayments` should find. Set per test. */
+const mockSearchResults = vi.fn<() => unknown[]>(() => []);
+
+vi.mock("stripe", () => ({
+ default: class {
+ paymentIntents = {
+ search: vi.fn(async () => ({ data: mockSearchResults() })),
+ retrieve: vi.fn(async (id: string) => {
+ throw new Error(`No such payment_intent: ${id}`);
+ }),
+ create: vi.fn(async () => ({
+ id: "pi_stub",
+ client_secret: "pi_stub_secret",
+ })),
+ };
+ checkout = {
+ sessions: {
+ create: vi.fn(async () => ({ id: "cs_stub", url: "https://stub" })),
+ },
+ };
+ },
+}));
+
vi.mock("@query/db", () => {
const table = (name: string) => ({
findFirst: (...args: any[]) => mockFindFirst(name, ...args),
@@ -34,6 +65,7 @@ vi.mock("@query/db", () => {
members: table("members"),
hackathons: table("hackathons"),
stripePayments: table("stripePayments"),
+ membershipHistory: table("membershipHistory"),
userAccountLinks: table("userAccountLinks"),
admins: table("admins"),
},
@@ -146,11 +178,58 @@ describe("Membership payments", () => {
const result = await caller().stripe.createPaymentIntent();
- expect(result).toEqual({
+ expect(result).toMatchObject({
clientSecret: "mock_pi_secret",
publishableKey: "pk_test_local",
isMock: true,
});
+ // A unique id per call, so two developers (or two runs) do not collide
+ // on confirmMembershipAfterPayment's idempotency check.
+ expect(result.mockPaymentIntentId).toMatch(/^pi_mock_[0-9a-f]{32}$/);
+ });
+
+ /**
+ * The whole point of mock mode. It previously returned a fake secret and
+ * wrote nothing, while the modal called onSuccess() directly — so the UI
+ * said "Access Granted" with no payment row and no member row anywhere,
+ * and the club half could not be developed locally at all.
+ *
+ * Asserting the returned shape (as the test above does) proves nothing
+ * about what was written, which is exactly how this survived the suite.
+ */
+ it("grants a real membership through the production confirm path", async () => {
+ process.env.STRIPE_MOCK_MODE = "true";
+
+ const { mockPaymentIntentId } = await caller().stripe.createPaymentIntent();
+
+ await caller().stripe.confirmMembershipAfterPayment({
+ paymentIntentId: mockPaymentIntentId!,
+ });
+
+ // This file mocks insert as mockInsert(valArgs), so the row is c[0][0].
+ const written = mockInsert.mock.calls.map((c) => c[0]?.[0]);
+ // A payment row, recorded under the same synthetic session id the
+ // webhook uses so the two settle each other's race.
+ expect(
+ written.some((row) => row?.stripeSessionId === `pi_${mockPaymentIntentId}`),
+ ).toBe(true);
+ // And the membership itself.
+ expect(written.some((row) => row?.userId === USER && row?.firstName)).toBe(
+ true,
+ );
+ });
+
+ // isMockMode() is false whenever NODE_ENV=production regardless of the
+ // flag, so the live site cannot be talked into minting free memberships.
+ it("refuses a mock intent id when not in mock mode", async () => {
+ delete process.env.STRIPE_MOCK_MODE;
+ process.env.STRIPE_SECRET_KEY = "sk_test_abc";
+
+ await expect(
+ caller().stripe.confirmMembershipAfterPayment({
+ paymentIntentId: "pi_mock_deadbeefdeadbeefdeadbeefdeadbeef",
+ }),
+ ).rejects.toThrow();
});
it("falls back to a placeholder publishable key when none is set", async () => {
@@ -290,4 +369,73 @@ describe("Membership payments", () => {
expect(insertedAmount()).toBe(MEMBERSHIP_CENTS + BOOTCAMP_ADDON_CENTS);
});
});
+
+ /**
+ * Reported by review on #316, and correct.
+ *
+ * The webhook records the payment first and grants the membership after, so
+ * a grant that throws leaves a payment row linked to the user with no
+ * membership behind it. Every recovery path skipped already-linked payments,
+ * which made that state permanent: charged customer, payment on file,
+ * nothing ever retrying.
+ */
+ describe("recovering a payment whose membership grant failed", () => {
+ const PAID_AT = new Date("2026-03-01T12:00:00Z");
+
+ const paidIntent = {
+ id: "pi_stranded",
+ amount: MEMBERSHIP_CENTS,
+ currency: "usd",
+ status: "succeeded",
+ metadata: { type: "membership", userId: USER },
+ };
+
+ const wire = (opts: { history?: unknown }) => {
+ process.env.STRIPE_SECRET_KEY = "sk_test_abc";
+ mockSearchResults.mockReturnValue([paidIntent]);
+ mockFindFirst.mockImplementation((table: string) => {
+ if (table === "users")
+ return { id: USER, email: "member@gatech.edu", name: "Buzz Member" };
+ if (table === "stripePayments")
+ return {
+ id: "pay_1",
+ stripePaymentIntentId: paidIntent.id,
+ linkedUserId: USER,
+ paymentStatus: "paid",
+ createdAt: PAID_AT,
+ };
+ if (table === "members") return { id: "member_1" };
+ if (table === "membershipHistory") return opts.history;
+ return undefined;
+ });
+ };
+
+ it("grants the membership when no history row covers the payment", async () => {
+ wire({ history: undefined });
+
+ const res = await caller().stripe.reconcileMyPayments();
+
+ expect(res.recovered).toBe(1);
+ // A member row already exists (the profile), so the term is written as a
+ // renewal — what matters is that a history row records the grant at all.
+ const written = mockInsert.mock.calls.map((c) => c[0]?.[0]);
+ expect(
+ written.some((row) => row?.action === "renewed" || row?.action === "joined"),
+ ).toBe(true);
+ });
+
+ /**
+ * The other half of the rule: a membership granted a year ago and since
+ * lapsed must NOT be silently renewed off that old payment. The history row
+ * is what distinguishes "never honoured" from "honoured and expired".
+ */
+ it("leaves an already-honoured payment alone", async () => {
+ wire({ history: { id: "hist_1" } });
+
+ const res = await caller().stripe.reconcileMyPayments();
+
+ expect(res.recovered).toBe(0);
+ expect(mockInsert).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/packages/api/src/middleware/audit.ts b/packages/api/src/middleware/audit.ts
new file mode 100644
index 00000000..7cbdd8c2
--- /dev/null
+++ b/packages/api/src/middleware/audit.ts
@@ -0,0 +1,115 @@
+import { auditLogs } from "@query/db";
+import { and, lt, ne } from "drizzle-orm";
+import type { DrizzleDB } from "@query/db";
+
+/**
+ * How long a security or admin event is kept.
+ *
+ * Retention used to run from a GitHub Actions cron hitting a public route with
+ * a bearer secret. That is three moving parts — a schedule, a shared secret,
+ * and an internet-reachable endpoint whose only protection is that secret —
+ * for a job whose entire content is two DELETEs. If the workflow was disabled,
+ * the repo was renamed, or the secret rotated, retention stopped silently and
+ * nothing anywhere reported it.
+ *
+ * Retention is now tied to writes instead. Audit rows only accumulate when
+ * something writes them, so pruning on write is self-regulating: a busy period
+ * prunes often, an idle one has nothing to prune. No scheduler, no endpoint,
+ * no secret.
+ */
+const RETAIN_DAYS = 90;
+/** Critical events outlive the routine window; they are the ones worth keeping. */
+const RETAIN_CRITICAL_DAYS = 365;
+
+/** At most one prune per process per interval, however many rows are written. */
+const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
+
+let lastPruneAt = 0;
+let pruneInFlight = false;
+
+const cutoff = (days: number) =>
+ new Date(Date.now() - days * 24 * 60 * 60 * 1000);
+
+/**
+ * Deletes expired audit rows, at most hourly per process.
+ *
+ * Deliberately not awaited by callers and deliberately silent on failure:
+ * retention is housekeeping, and a full audit table is a much smaller problem
+ * than an admin action that fails because housekeeping did.
+ */
+export const maybePruneAuditLogs = (db: DrizzleDB) => {
+ const now = Date.now();
+ if (pruneInFlight || now - lastPruneAt < PRUNE_INTERVAL_MS) return;
+
+ // Stamped before the await, so concurrent requests in the same process do
+ // not all decide to prune at once.
+ lastPruneAt = now;
+ pruneInFlight = true;
+
+ void (async () => {
+ try {
+ // Both bound on created_at, which audit_created_at_idx covers.
+ await db
+ .delete(auditLogs)
+ .where(
+ and(
+ lt(auditLogs.createdAt, cutoff(RETAIN_DAYS)),
+ ne(auditLogs.severity, "critical"),
+ ),
+ );
+
+ await db
+ .delete(auditLogs)
+ .where(lt(auditLogs.createdAt, cutoff(RETAIN_CRITICAL_DAYS)));
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error("[Audit] Retention prune failed:", error);
+ } finally {
+ pruneInFlight = false;
+ }
+ })();
+};
+
+/**
+ * Records an administrative action.
+ *
+ * `audit_logs` already had a table, an admin reader and a severity enum, but
+ * its only writer was the security middleware's four rate-limit event types —
+ * so every guard on the destructive paths was the last line of defence with
+ * nothing behind it. When somebody forces past a confirmation at 2am, this is
+ * the only thing that can say who, what and when afterwards.
+ *
+ * Deliberately fire-and-forget: an audit write must never be the reason an
+ * organiser's action fails. A delete that succeeded and went unrecorded is bad;
+ * a delete that was refused because the logging table was busy is worse, and
+ * would be indistinguishable from the guard doing its job.
+ */
+export const recordAdminAction = async (
+ db: DrizzleDB,
+ entry: {
+ userId: string | null | undefined;
+ action: string;
+ resourceId?: string | null;
+ /** `critical` for anything irreversible or forced past a refusal. */
+ severity?: "info" | "warn" | "critical";
+ metadata?: Record;
+ },
+) => {
+ try {
+ await db.insert(auditLogs).values({
+ userId: entry.userId ?? null,
+ action: entry.action,
+ resourceId: entry.resourceId ?? null,
+ severity: entry.severity ?? "info",
+ metadata: entry.metadata ?? {},
+ });
+
+ // Housekeeping rides along with the write that created the need for it.
+ maybePruneAuditLogs(db);
+ } catch (error) {
+ // Deliberate server-side logging: if the audit trail itself cannot be
+ // written, the console is the only remaining record that it was tried.
+ // eslint-disable-next-line no-console
+ console.error(`[Audit] Failed to record "${entry.action}":`, error);
+ }
+};
diff --git a/packages/api/src/middleware/cache.ts b/packages/api/src/middleware/cache.ts
index 8f7f6b96..74f9ffda 100644
--- a/packages/api/src/middleware/cache.ts
+++ b/packages/api/src/middleware/cache.ts
@@ -268,7 +268,10 @@ export const clearProjectLeaderCaches = (userId: string) => {
* member being told to pay again.
*/
export const clearMembershipCaches = (userId: string) => {
- cache.deletePattern(`${CacheKeys.member(userId)}*`);
+ // `member:*` is a shape nothing writes — member.me stores
+ // `member:me:`, so a webhook grant used to leave that entry stale and
+ // the member was told to pay for another minute. Evict what is written.
+ cache.deletePattern(`member:me:${userId}*`);
cache.deletePattern(`member:status:${userId}*`);
invalidatePortalContext(userId);
};
diff --git a/packages/api/src/middleware/db-errors.ts b/packages/api/src/middleware/db-errors.ts
new file mode 100644
index 00000000..966cfb19
--- /dev/null
+++ b/packages/api/src/middleware/db-errors.ts
@@ -0,0 +1,25 @@
+/**
+ * Postgres unique_violation. Drizzle wraps every driver error in a
+ * DrizzleQueryError, which carries no `code` — the pg error holding the
+ * SQLSTATE sits on `.cause` — so the chain has to be walked. Checking only the
+ * top-level object silently never matches in production, however well it works
+ * against a mock that throws a bare `{ code: "23505" }`.
+ */
+const hasSqlState = (error: unknown, code: string) => {
+ for (let cursor = error, depth = 0; cursor && depth < 5; depth++) {
+ if (typeof cursor !== "object") break;
+ if ((cursor as { code?: string }).code === code) return true;
+ cursor = (cursor as { cause?: unknown }).cause;
+ }
+ return false;
+};
+
+export const isUniqueViolation = (error: unknown) => hasSqlState(error, "23505");
+
+/**
+ * Postgres foreign_key_violation. Raised when an ON DELETE RESTRICT reference
+ * still points at the row being deleted — which is exactly what protects paid
+ * club memberships from a hackathon delete.
+ */
+export const isForeignKeyViolation = (error: unknown) =>
+ hasSqlState(error, "23503");
diff --git a/packages/api/src/middleware/procedures.ts b/packages/api/src/middleware/procedures.ts
index 7ba1282c..3ec73fec 100644
--- a/packages/api/src/middleware/procedures.ts
+++ b/packages/api/src/middleware/procedures.ts
@@ -10,6 +10,7 @@ import {
import { eq, and } from "drizzle-orm";
import { CacheKeys } from "./cache";
import { resolveHackathonId } from "../services/portal-context";
+import { isStaffRole } from "../types/portal-context";
import type { Context } from "../context";
/**
@@ -32,23 +33,27 @@ export const callerIsAdmin = async (ctx: Context) => {
where: and(eq(admins.userId, ctx.userId), eq(admins.isActive, true)),
});
- ctx.cache.set(cacheKey, !!admin, 60);
+ const isStaff = !!admin && admin.role !== "volunteer";
- return !!admin;
+ ctx.cache.set(cacheKey, isStaff, 60);
+
+ return isStaff;
};
+
/**
- * Middleware that verifies the current user is an active admin.
- * Result is cached for 60s per user to avoid a DB round-trip on every request.
+ * Loads the caller's active admin row, cached 60s per user.
+ *
+ * Shared by isScanner and isAdmin so a check-in station and a staff action
+ * cost the same single lookup.
*/
-export const isAdmin = protectedProcedure.use(async ({ ctx, next }) => {
+const loadAdminRow = async (ctx: Context) => {
const cacheKey = `${CacheKeys.admin(ctx.userId as string)}:role`;
let admin = ctx.cache.get(cacheKey);
if (!admin) {
admin =
(await (ctx.db as NonNullable).query.admins.findFirst({
- // try catch for ctx.db
where: and(
eq(admins.userId, ctx.userId as string),
eq(admins.isActive, true),
@@ -58,7 +63,38 @@ export const isAdmin = protectedProcedure.use(async ({ ctx, next }) => {
if (admin) ctx.cache.set(cacheKey, admin, 60);
}
+ return admin;
+};
+
+/**
+ * Anyone staffing the event, volunteers included.
+ *
+ * Scoped to badge scanning and its undo. A 2000-person event runs several
+ * check-in stations, and the people on them should not need the role that can
+ * delete the hackathon and cascade every participant, team and vote with it.
+ */
+export const isScanner = protectedProcedure.use(async ({ ctx, next }) => {
+ const admin = await loadAdminRow(ctx);
+
if (!admin) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Event staff access required",
+ });
+ }
+
+ return next({ ctx: { ...ctx, admin } });
+});
+
+/**
+ * Full staff. Volunteers are deliberately rejected here — they hold an admins
+ * row, so without the role check they would pass every admin gate in the API.
+ * Result is cached for 60s per user to avoid a DB round-trip on every request.
+ */
+export const isAdmin = protectedProcedure.use(async ({ ctx, next }) => {
+ const admin = await loadAdminRow(ctx);
+
+ if (!admin || !isStaffRole(admin.role)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin access required",
diff --git a/packages/api/src/middleware/security.ts b/packages/api/src/middleware/security.ts
index 0fb6a0e6..fd443f43 100644
--- a/packages/api/src/middleware/security.ts
+++ b/packages/api/src/middleware/security.ts
@@ -523,14 +523,21 @@ export function getRecentSecurityEvents(minutes: number = 60): SecurityEvent[] {
return securityLog.filter((e) => e.timestamp > cutoff);
}
-export function ddosProtection(clientIp: string): {
+/**
+ * Coarse per-caller flood protection.
+ *
+ * `key` is an identity when we have one and an address only when we do not —
+ * callers must prefix it (`user:` / `ip:`) so the two namespaces can never
+ * collide. Keying on the address alone puts an entire venue behind one NAT into
+ * a single bucket, which is exactly the crowd this is supposed to serve.
+ */
+export function ddosProtection(key: string): {
allowed: boolean;
retryAfter?: number;
} {
const now = Date.now();
- // Get or create IP record
- let record = ipTrackingStore.get(clientIp);
+ let record = ipTrackingStore.get(key);
if (!record) {
record = {
requests: 0,
@@ -539,15 +546,14 @@ export function ddosProtection(clientIp: string): {
isBlocked: false,
blockedUntil: 0,
};
- ipTrackingStore.set(clientIp, record);
+ ipTrackingStore.set(key, record);
}
- // Check if IP is blocked
if (record.isBlocked && now < record.blockedUntil) {
logSecurityEvent({
type: "rate_limit",
- identifier: clientIp,
- details: `Blocked IP attempted access`,
+ identifier: key,
+ details: `Blocked caller attempted access`,
});
return {
allowed: false,
@@ -576,7 +582,7 @@ export function ddosProtection(clientIp: string): {
logSecurityEvent({
type: "rate_limit",
- identifier: clientIp,
+ identifier: key,
details: `Burst attack detected: ${record.requests} requests in ${elapsed}ms`,
});
@@ -594,7 +600,7 @@ export function ddosProtection(clientIp: string): {
logSecurityEvent({
type: "rate_limit",
- identifier: clientIp,
+ identifier: key,
details: `Sustained attack: ${record.requests} requests/minute`,
});
diff --git a/packages/api/src/routers/admin.ts b/packages/api/src/routers/admin.ts
index 33a3a5cb..12d1f0e2 100644
--- a/packages/api/src/routers/admin.ts
+++ b/packages/api/src/routers/admin.ts
@@ -56,6 +56,19 @@ export const adminRouter = createTRPCRouter({
}),
analyticsOverview: isAdmin.query(async ({ ctx }) => {
+ // The analytics page polls this every 5s and leaves it open all weekend.
+ // Five uncached aggregates per poll per open dashboard is a standing load
+ // for numbers nobody watches change second by second; a 15s entry means at
+ // most one round of aggregates per 15s no matter how many tabs are up.
+ const cacheKey = "admin:analytics-overview";
+ const cached = ctx.cache.get<{
+ totalParticipants: number;
+ totalEvents: number;
+ totalHackathons: number;
+ checkinsToday: number;
+ }>(cacheKey);
+ if (cached !== null) return cached;
+
const startOfToday = new Date();
startOfToday.setHours(0, 0, 0, 0);
@@ -87,13 +100,17 @@ export const adminRouter = createTRPCRouter({
.where(gte(eventCheckIns.checkedInAt, startOfToday)),
]);
- return {
+ const result = {
totalParticipants: participantsResult[0]?.count ?? 0,
totalEvents: eventsResult[0]?.count ?? 0,
totalHackathons: hackathonsResult[0]?.count ?? 0,
checkinsToday:
(badgeScansResult[0]?.count ?? 0) + (doorCheckinsResult[0]?.count ?? 0),
};
+
+ ctx.cache.set(cacheKey, result, 15);
+
+ return result;
}),
list: isAdmin.query(async ({ ctx }) => {
diff --git a/packages/api/src/routers/events.ts b/packages/api/src/routers/events.ts
index 65544c3b..aa633032 100644
--- a/packages/api/src/routers/events.ts
+++ b/packages/api/src/routers/events.ts
@@ -5,8 +5,6 @@ import { events, eventCheckIns, members } from "@query/db";
import { eq, and, lt, sql } from "drizzle-orm";
import { randomUUID } from "crypto";
import { isAdmin } from "../middleware/procedures";
-import { resolveHackathonId } from "../services/portal-context";
-import type { DrizzleDB } from "@query/db";
/**
* Postgres unique_violation. Drizzle wraps every driver error in a
@@ -52,6 +50,64 @@ export const eventRouter = createTRPCRouter({
return newEvent;
}),
+ /**
+ * Corrects a club event in place.
+ *
+ * Without this the only way to fix a typo in a title was to delete the event
+ * and make a new one — which destroys every check-in already collected
+ * against it, and mints a new QR code that the printed one no longer matches.
+ */
+ update: isAdmin
+ .input(
+ z.object({
+ eventId: z.string().uuid(),
+ title: z.string().min(1).max(200).optional(),
+ description: z.string().max(1000).nullable().optional(),
+ location: z.string().max(200).nullable().optional(),
+ eventDate: z.date().optional(),
+ /** Null removes the cap. */
+ maxCheckIns: z.number().int().positive().nullable().optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { eventId, ...fields } = input;
+
+ const existing = await (
+ ctx.db as NonNullable
+ ).query.events.findFirst({
+ where: eq(events.id, eventId),
+ columns: { currentCheckIns: true },
+ });
+
+ if (!existing) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
+ }
+
+ // A cap below the number of people already scanned would make the counter
+ // read as over-full forever and refuse everyone at the door, with nothing
+ // saying why.
+ if (
+ typeof fields.maxCheckIns === "number" &&
+ fields.maxCheckIns < existing.currentCheckIns
+ ) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: `${existing.currentCheckIns} people have already checked in, so the cap cannot be lower than that.`,
+ });
+ }
+
+ const [updated] = await (ctx.db as NonNullable)
+ .update(events)
+ .set({ ...fields, updatedAt: new Date() })
+ .where(eq(events.id, eventId))
+ .returning();
+
+ ctx.cache.deletePattern(`event:${eventId}`);
+ ctx.cache.deletePattern("event*");
+
+ return updated;
+ }),
+
regenerateQR: isAdmin
.input(z.object({ eventId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
@@ -268,21 +324,14 @@ export const eventRouter = createTRPCRouter({
.where(eq(events.id, event.id))
.for("update");
- // One membership row per edition, so an unscoped lookup can pick a
- // lapsed earlier year.
- const hackathonId = await resolveHackathonId(
- tx as unknown as DrizzleDB,
- );
-
const [member, existingCheckIn] = await Promise.all([
- hackathonId
- ? tx.query.members.findFirst({
- where: and(
- eq(members.userId, ctx.userId as string),
- eq(members.hackathonId, hackathonId),
- ),
- })
- : undefined,
+ // Club check-in no longer depends on a hackathon edition existing.
+ // It used to skip this lookup entirely when none resolved, and
+ // then refuse everyone at the door with "Must be a member" — at a
+ // club event that has nothing to do with any hackathon.
+ tx.query.members.findFirst({
+ where: eq(members.userId, ctx.userId as string),
+ }),
tx.query.eventCheckIns.findFirst({
where: and(
eq(eventCheckIns.eventId, event.id),
diff --git a/packages/api/src/routers/hackathon/admin.ts b/packages/api/src/routers/hackathon/admin.ts
index b5b917f0..c3dbec6c 100644
--- a/packages/api/src/routers/hackathon/admin.ts
+++ b/packages/api/src/routers/hackathon/admin.ts
@@ -1,12 +1,15 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter } from "../../trpc";
-import { isAdmin } from "../../middleware/procedures";
+import { isAdmin, isScanner } from "../../middleware/procedures";
+import { isUniqueViolation } from "../../middleware/db-errors";
+import { recordAdminAction } from "../../middleware/audit";
import {
hackathons,
hackathonParticipants,
hackathonEvents,
hackathonEventAttendees,
+ users,
} from "@query/db";
import { eq, and, inArray, sql } from "drizzle-orm";
import type { DrizzleDB } from "@query/db";
@@ -41,19 +44,78 @@ const syncCurrentParticipants = (db: DrizzleDB, hackathonId: string) =>
});
/**
- * Postgres unique_violation. Drizzle wraps every driver error in a
- * DrizzleQueryError, which carries no `code` — the pg error holding the
- * SQLSTATE sits on `.cause` — so the chain has to be walked. Checking only the
- * top-level object silently never matches in production, however well it works
- * against a mock that throws a bare `{ code: "23505" }`.
+ * Evicts exactly the keys a participant status change moves.
+ *
+ * The old `deletePattern("hackathon*")` matched both the `hackathon:` and
+ * `hackathons:` namespaces, so a single badge scan wiped every attendee's
+ * cached registrations and the events list the whole venue reads. At 2000
+ * people that turns a once-per-TTL query into a per-request one, during the
+ * hour the schedule page is busiest.
+ *
+ * Each affected user's own registration list has to go too, or an acceptance
+ * lands in somebody's inbox while their dashboard still says pending.
+ */
+const evictParticipantCaches = (
+ cache: { delete: (key: string) => boolean },
+ hackathonId: string,
+ userIds: string[],
+) => {
+ cache.delete(`hackathon:${hackathonId}:participants`);
+ cache.delete(`hackathon:${hackathonId}:analytics`);
+ for (const userId of new Set(userIds)) {
+ cache.delete(`hackathon:registrations:${userId}`);
+ }
+};
+
+const PARTICIPANT_STATUSES = z.enum([
+ "pending",
+ "approved",
+ "rejected",
+ "waitlisted",
+ "checked_in",
+]);
+
+/**
+ * The WHERE shared by the paged roster and the CSV export, so the file an
+ * organiser downloads always matches the list they were looking at.
+ *
+ * Search covers the same fields the old client-side filter did. ILIKE rather
+ * than lower(...) LIKE because it reads as what it is; neither uses an index
+ * at this row count, and 2000 rows is well inside what a scan handles.
*/
-const isUniqueViolation = (error: unknown) => {
- for (let cursor = error, depth = 0; cursor && depth < 5; depth++) {
- if (typeof cursor !== "object") break;
- if ((cursor as { code?: string }).code === "23505") return true;
- cursor = (cursor as { cause?: unknown }).cause;
+const buildAttendeeWhere = (input: {
+ hackathonId: string;
+ search?: string;
+ status?: z.infer;
+}) => {
+ const clauses = [eq(hackathonParticipants.hackathonId, input.hackathonId)];
+
+ if (input.status) {
+ clauses.push(eq(hackathonParticipants.registrationStatus, input.status));
}
- return false;
+
+ const term = input.search?.trim();
+ if (term) {
+ // Escaped so a literal % or _ in somebody's name searches for that
+ // character instead of turning into a wildcard.
+ const pattern = `%${term.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
+ clauses.push(
+ sql`(
+ ${hackathonParticipants.firstName} ilike ${pattern}
+ or ${hackathonParticipants.lastName} ilike ${pattern}
+ or ${hackathonParticipants.school} ilike ${pattern}
+ or ${hackathonParticipants.major} ilike ${pattern}
+ or ${hackathonParticipants.whyAttend} ilike ${pattern}
+ or exists (
+ select 1 from ${users}
+ where ${users.id} = ${hackathonParticipants.userId}
+ and (${users.name} ilike ${pattern} or ${users.email} ilike ${pattern})
+ )
+ )`,
+ );
+ }
+
+ return and(...clauses);
};
export const hackathonAdminRouter = createTRPCRouter({
@@ -61,33 +123,109 @@ export const hackathonAdminRouter = createTRPCRouter({
.input(
z.object({
hackathonId: z.string().uuid("Invalid hackathon ID"),
+ limit: z.number().int().min(1).max(200).default(50),
+ offset: z.number().int().min(0).default(0),
+ search: z.string().trim().max(200).optional(),
+ status: PARTICIPANT_STATUSES.optional(),
}),
)
.query(async ({ ctx, input }) => {
- const attendees = await (
- ctx.db as DrizzleDB
- ).query.hackathonParticipants.findMany({
- where: eq(hackathonParticipants.hackathonId, input.hackathonId),
- with: {
- user: {
- columns: {
- id: true,
- name: true,
- email: true,
- image: true,
- },
- },
- team: {
- columns: {
- id: true,
- name: true,
+ const db = ctx.db as DrizzleDB;
+
+ // Filtering happens in the database, not in the browser. The old version
+ // shipped every participant row — 35 columns including resumes, phone
+ // numbers and 2000-character essays — so the client could filter an array
+ // it had already downloaded. At 2000 attendees that is megabytes of PII
+ // per keystroke-triggered refetch.
+ const where = buildAttendeeWhere(input);
+
+ const [rows, [totals]] = await Promise.all([
+ db.query.hackathonParticipants.findMany({
+ where,
+ with: {
+ user: {
+ columns: { id: true, name: true, email: true, image: true },
},
+ team: { columns: { id: true, name: true } },
},
+ orderBy: (participants, { desc }) => [desc(participants.registeredAt)],
+ limit: input.limit,
+ offset: input.offset,
+ }),
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(hackathonParticipants)
+ .where(where),
+ ]);
+
+ return {
+ attendees: rows,
+ // How many match the current filter, so the pager knows where it ends.
+ // Deliberately not the unfiltered total: those are different numbers
+ // and conflating them makes the last page unreachable.
+ matching: totals?.count ?? 0,
+ limit: input.limit,
+ offset: input.offset,
+ };
+ }),
+
+ /**
+ * Just the ids matching the current filter.
+ *
+ * Exists so "select all" can mean every matching applicant rather than the
+ * fifty on screen. Pagination made the header checkbox select one page, and
+ * a bulk approve that silently covers 50 of 2000 while reporting success is
+ * worse than one that fails outright — the organiser moves on believing the
+ * queue is cleared.
+ *
+ * Ids rather than rows: 2000 uuids is a small payload, and keeping the
+ * mutation id-based means the set is fixed at the moment the organiser
+ * chose it, instead of re-evaluating a filter that may have moved.
+ */
+ adminGetAttendeeIds: isAdmin
+ .input(
+ z.object({
+ hackathonId: z.string().uuid("Invalid hackathon ID"),
+ search: z.string().trim().max(200).optional(),
+ status: PARTICIPANT_STATUSES.optional(),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const rows = await (ctx.db as DrizzleDB)
+ .select({ id: hackathonParticipants.id })
+ .from(hackathonParticipants)
+ .where(buildAttendeeWhere(input))
+ // Matches the batch mutation's own cap, so a selection can always be
+ // acted on in a single call.
+ .limit(2500);
+
+ return rows.map((row) => row.id);
+ }),
+
+ /**
+ * The whole filtered roster, for CSV export.
+ *
+ * Its own endpoint rather than a flag on adminGetAttendees so the one call
+ * that hands over every attendee's PII is explicit at the call site and can
+ * be audited or restricted on its own later.
+ */
+ exportAttendees: isAdmin
+ .input(
+ z.object({
+ hackathonId: z.string().uuid("Invalid hackathon ID"),
+ search: z.string().trim().max(200).optional(),
+ status: PARTICIPANT_STATUSES.optional(),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ return await (ctx.db as DrizzleDB).query.hackathonParticipants.findMany({
+ where: buildAttendeeWhere(input),
+ with: {
+ user: { columns: { id: true, name: true, email: true } },
+ team: { columns: { id: true, name: true } },
},
orderBy: (participants, { desc }) => [desc(participants.registeredAt)],
});
-
- return attendees;
}),
@@ -136,7 +274,7 @@ export const hackathonAdminRouter = createTRPCRouter({
await syncCurrentParticipants(ctx.db as DrizzleDB, input.hackathonId);
- ctx.cache.deletePattern("hackathon*");
+ evictParticipantCaches(ctx.cache, input.hackathonId, [participant.userId]);
return { success: true };
}),
@@ -146,7 +284,16 @@ export const hackathonAdminRouter = createTRPCRouter({
.input(
z.object({
hackathonId: z.string().uuid("Invalid hackathon ID"),
- participantIds: z.array(z.string().uuid()).min(1),
+ // Each id is one SMTP round trip. 500 is roughly what fits inside a
+ // Cloud Run request, and it matches the daily ceiling of the consumer
+ // Gmail account this currently sends through — the UI chunks a larger
+ // selection rather than handing the request a batch it cannot finish.
+ participantIds: z.array(z.string().uuid()).min(1).max(500),
+ /** Mail people who have already had their acceptance. Off by default:
+ * the ordinary reason to run this twice is that the first run died
+ * partway, and then everyone before the failure point is already
+ * done. */
+ resend: z.boolean().default(false),
}),
)
.mutation(async ({ ctx, input }) => {
@@ -180,49 +327,113 @@ export const hackathonAdminRouter = createTRPCRouter({
})
).filter((participant) => participant.hackathonId === hackathonId);
- await db.transaction(async (tx) => {
- for (const participant of participants) {
- await tx
- .update(hackathonParticipants)
- .set({ registrationStatus: "approved", updatedAt: new Date() })
- .where(
- and(
- eq(hackathonParticipants.id, participant.id),
- eq(hackathonParticipants.hackathonId, hackathonId),
- ),
- );
- }
- });
+ if (participants.length === 0) {
+ return {
+ success: true,
+ approved: 0,
+ emailed: 0,
+ failedEmails: [] as string[],
+ skipped: participantIds.length,
+ message: `None of the ${participantIds.length} id(s) are registered for this hackathon.`,
+ };
+ }
+
+ // 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),
+ ),
+ );
// Approving a rejected or waitlisted applicant hands a seat back out.
await syncCurrentParticipants(db, hackathonId);
+ const { sendAcceptanceEmail } = await import("@query/auth/email");
+
+ let emailed = 0;
+ let alreadyEmailed = 0;
+ const failedEmails: string[] = [];
+
for (const participant of participants) {
- if (participant.user?.email) {
- try {
- const { sendAcceptanceEmail } = await import("@query/auth/email");
- await sendAcceptanceEmail({
- email: participant.user.email,
- hackathonName: hackathon.name,
- host: process.env.NEXTAUTH_URL || "https://datasciencegt.org"
- });
- // Deliberate server-side operational logging: acceptance emails are
- // sent in a loop and individual failures are swallowed below, so
- // these lines are the only record of what actually went out.
- // eslint-disable-next-line no-console
- console.log(`[Email Service] Sent acceptance email to ${participant.user.email} for hackathon ${hackathon.name}.`);
- } catch (error) {
- // eslint-disable-next-line no-console
- console.error(`[Email Service] Failed to send acceptance email to ${participant.user.email}:`, error);
- }
+ if (!participant.user?.email) continue;
+
+ // The marker is read here, not just written below. Re-running this
+ // after a batch died partway through is the normal recovery, and
+ // without this check everyone before the failure point is congratulated
+ // a second time — which cannot be taken back.
+ if (participant.acceptanceEmailSentAt && !input.resend) {
+ alreadyEmailed++;
+ continue;
+ }
+
+ try {
+ await sendAcceptanceEmail({
+ email: participant.user.email,
+ hackathonName: hackathon.name,
+ host: process.env.NEXTAUTH_URL || "https://datasciencegt.org"
+ });
+ // Stamped one row at a time, immediately after the send. A batch of
+ // hundreds can die partway through — Cloud Run kills the request at
+ // 300s — and this marker is what keeps a retry from mailing everyone
+ // who already heard from us a second time.
+ await db
+ .update(hackathonParticipants)
+ .set({ acceptanceEmailSentAt: new Date() })
+ .where(eq(hackathonParticipants.id, participant.id));
+ emailed++;
+ } catch (error) {
+ failedEmails.push(participant.user.email);
+ // Deliberate server-side operational logging: this is the only record
+ // of which address the provider rejected.
+ // eslint-disable-next-line no-console
+ console.error(`[Email Service] Failed to send acceptance email to ${participant.user.email}:`, error);
}
}
- ctx.cache.deletePattern("hackathon*");
+ // Thousands of emails that cannot be unsent, in one action.
+ await recordAdminAction(db, {
+ userId: ctx.userId,
+ action: "hackathon.sendMassAcceptanceEmails",
+ resourceId: hackathonId,
+ severity: "warn",
+ metadata: {
+ approved: participants.length,
+ emailed,
+ alreadyEmailed,
+ failed: failedEmails.length,
+ resend: input.resend,
+ },
+ });
+
+ evictParticipantCaches(
+ ctx.cache,
+ hackathonId,
+ participants.map((participant) => participant.userId),
+ );
const skipped = participantIds.length - participants.length;
- return { success: true, count: participants.length, skipped, message: `Successfully approved and sent acceptance emails to ${participants.length} participants.${skipped > 0 ? ` ${skipped} id(s) are not registered for this hackathon and were skipped.` : ""}` };
+ // Approved and emailed are reported separately because they genuinely
+ // differ: the provider throttles, addresses bounce, and an organiser told
+ // "sent to 500" when 80 were delivered has no reason to look again.
+ return {
+ success: true,
+ approved: participants.length,
+ emailed,
+ alreadyEmailed,
+ failedEmails,
+ skipped,
+ message: `Approved ${participants.length} participant(s); ${emailed} acceptance email(s) sent.${alreadyEmailed > 0 ? ` ${alreadyEmailed} had already been emailed and were left alone.` : ""}${failedEmails.length > 0 ? ` ${failedEmails.length} could not be delivered.` : ""}${skipped > 0 ? ` ${skipped} id(s) are not registered for this hackathon and were skipped.` : ""}`,
+ };
}),
@@ -230,7 +441,10 @@ export const hackathonAdminRouter = createTRPCRouter({
.input(
z.object({
hackathonId: z.string().uuid("Invalid hackathon ID"),
- participantIds: z.array(z.string().uuid()).min(1).max(500),
+ // Sized for one organiser selecting every applicant at a 2000-person
+ // event. The bound stays — an unbounded array is a memory ceiling, not
+ // a feature — but 500 silently rejected the whole selection.
+ participantIds: z.array(z.string().uuid()).min(1).max(2500),
status: z.enum([
"pending",
"approved",
@@ -243,102 +457,254 @@ export const hackathonAdminRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const { hackathonId, participantIds, status } = input;
- // Each UPDATE is scoped by (id, hackathonId), so an id pasted from
- // another hackathon matches nothing. The caller is told how many rows
- // really changed rather than how many ids were submitted.
- const updated = await (ctx.db as DrizzleDB).transaction(async (tx) => {
- let changed = 0;
- for (const participantId of participantIds) {
- const rows = await tx
- .update(hackathonParticipants)
- .set({
- registrationStatus: status,
- updatedAt: new Date(),
- // coalesce so a batch that re-checks in someone who already
- // arrived keeps their original arrival time. `at time zone 'utc'`
- // because the column is timestamp-without-tz and drizzle reads it
- // back as UTC — a bare now() would be cast through the session
- // TimeZone and disagree with the `new Date()` that
- // updateParticipantStatus writes for the very same event.
- ...(status === "checked_in"
- ? {
- checkedInAt: sql`coalesce(${hackathonParticipants.checkedInAt}, now() at time zone 'utc')`,
- }
- : {}),
- })
- .where(
- and(
- eq(hackathonParticipants.id, participantId),
- eq(hackathonParticipants.hackathonId, hackathonId),
- ),
- )
- .returning({ id: hackathonParticipants.id });
- changed += rows.length;
- }
- return changed;
- });
+ // One statement, not one per id: 2000 sequential round trips would hold a
+ // pool connection open for the whole batch. Scoping by (id, hackathonId)
+ // is preserved exactly by the AND, so an id pasted from another hackathon
+ // still matches nothing, and the caller is told how many rows really
+ // changed rather than how many ids were submitted.
+ const rows = await (ctx.db as DrizzleDB)
+ .update(hackathonParticipants)
+ .set({
+ registrationStatus: status,
+ updatedAt: new Date(),
+ // coalesce so a batch that re-checks in someone who already arrived
+ // keeps their original arrival time. `at time zone 'utc'` because the
+ // column is timestamp-without-tz and drizzle reads it back as UTC — a
+ // bare now() would be cast through the session TimeZone and disagree
+ // with the `new Date()` that updateParticipantStatus writes for the
+ // very 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),
+ ),
+ )
+ .returning({
+ id: hackathonParticipants.id,
+ userId: hackathonParticipants.userId,
+ });
await syncCurrentParticipants(ctx.db as DrizzleDB, hackathonId);
- ctx.cache.deletePattern("hackathon*");
+ evictParticipantCaches(
+ ctx.cache,
+ hackathonId,
+ rows.map((row) => row.userId),
+ );
- return { success: true, updated };
+ return { success: true, updated: rows.length };
}),
analytics: isAdmin
.input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") }))
.query(async ({ ctx, input }) => {
- const participants = await (
- ctx.db as DrizzleDB
- ).query.hackathonParticipants.findMany({
- where: eq(hackathonParticipants.hackathonId, input.hackathonId),
- });
-
- const stats = {
- totalRegistrations: participants.length,
- statusBreakdown: {
- approved: 0,
- pending: 0,
- rejected: 0,
- waitlisted: 0,
- checked_in: 0,
- },
- shirtSizes: {} as Record,
- dietaryRestrictions: {} as Record,
+ const db = ctx.db as DrizzleDB;
+ const scope = eq(hackathonParticipants.hackathonId, input.hackathonId);
+
+ // Counted by the database. This used to load every participant row —
+ // all 35 columns, including the essays — to produce a handful of
+ // integers, and it backs both the stat tiles and the analytics page.
+ const [byStatus, bySize, byDiet] = await Promise.all([
+ db
+ .select({
+ status: hackathonParticipants.registrationStatus,
+ count: sql`count(*)::int`,
+ })
+ .from(hackathonParticipants)
+ .where(scope)
+ .groupBy(hackathonParticipants.registrationStatus),
+ db
+ .select({
+ size: hackathonParticipants.shirtSize,
+ count: sql`count(*)::int`,
+ })
+ .from(hackathonParticipants)
+ .where(scope)
+ .groupBy(hackathonParticipants.shirtSize),
+ // unnest so each restriction in the array counts once, rather than
+ // pulling every array back to be flattened in JS.
+ db
+ .select({
+ restriction: sql`btrim(restriction)`.as("restriction"),
+ count: sql`count(*)::int`,
+ })
+ .from(hackathonParticipants)
+ .innerJoin(
+ sql`unnest(${hackathonParticipants.dietaryRestrictions}) as restriction`,
+ sql`true`,
+ )
+ .where(scope)
+ .groupBy(sql`btrim(restriction)`),
+ ]);
+
+ const statusBreakdown = {
+ approved: 0,
+ pending: 0,
+ rejected: 0,
+ waitlisted: 0,
+ checked_in: 0,
};
- participants.forEach((p) => {
- // Status breakdown
- if (p.registrationStatus in stats.statusBreakdown) {
- stats.statusBreakdown[
- p.registrationStatus as keyof typeof stats.statusBreakdown
- ]++;
+ let totalRegistrations = 0;
+ for (const row of byStatus) {
+ totalRegistrations += row.count;
+ if (row.status && row.status in statusBreakdown) {
+ statusBreakdown[row.status as keyof typeof statusBreakdown] =
+ row.count;
}
+ }
- // Shirt sizes
- if (p.shirtSize) {
- stats.shirtSizes[p.shirtSize] =
- (stats.shirtSizes[p.shirtSize] || 0) + 1;
- }
+ const shirtSizes: Record = {};
+ for (const row of bySize) {
+ if (row.size) shirtSizes[row.size] = row.count;
+ }
- // Dietary restrictions
- if (p.dietaryRestrictions && p.dietaryRestrictions.length > 0) {
- p.dietaryRestrictions.forEach((restriction) => {
- const normalized = restriction.trim();
- if (normalized) {
- stats.dietaryRestrictions[normalized] =
- (stats.dietaryRestrictions[normalized] || 0) + 1;
- }
- });
- }
+ const dietaryRestrictions: Record = {};
+ for (const row of byDiet) {
+ if (row.restriction) dietaryRestrictions[row.restriction] = row.count;
+ }
+
+ return {
+ totalRegistrations,
+ statusBreakdown,
+ shirtSizes,
+ dietaryRestrictions,
+ };
+ }),
+
+
+ /**
+ * Who scanned into one event.
+ *
+ * The scanner writes these rows and, until now, nothing ever read or removed
+ * them — so a station left pointed at the wrong event produced dozens of
+ * check-ins an organiser could see the count of but not the contents.
+ */
+ getEventAttendees: isScanner
+ .input(
+ z.object({
+ hackathonId: z.string().uuid("Invalid hackathon ID"),
+ eventId: z.string().uuid("Invalid event ID"),
+ limit: z.number().int().min(1).max(200).default(50),
+ offset: z.number().int().min(0).default(0),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ // Scoped through the event's own hackathonId rather than trusting the
+ // pair in the input, so an eventId from another edition returns nothing
+ // instead of that edition's roster.
+ const event = await db.query.hackathonEvents.findFirst({
+ where: and(
+ eq(hackathonEvents.id, input.eventId),
+ eq(hackathonEvents.hackathonId, input.hackathonId),
+ ),
+ columns: { id: true },
});
- return stats;
+ if (!event) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Event not found." });
+ }
+
+ const [rows, [totals]] = await Promise.all([
+ db.query.hackathonEventAttendees.findMany({
+ where: eq(hackathonEventAttendees.eventId, input.eventId),
+ with: {
+ participant: {
+ columns: { id: true, firstName: true, lastName: true },
+ with: { user: { columns: { name: true, email: true } } },
+ },
+ },
+ orderBy: (attendees, { desc }) => [desc(attendees.checkedInAt)],
+ limit: input.limit,
+ offset: input.offset,
+ }),
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(hackathonEventAttendees)
+ .where(eq(hackathonEventAttendees.eventId, input.eventId)),
+ ]);
+
+ return { attendees: rows, matching: totals?.count ?? 0 };
}),
+ /**
+ * Undoes one scan.
+ *
+ * The scan path is deliberately hard to fool — a duplicate is a CONFLICT and
+ * an ended event is a FORBIDDEN — but none of that helps when the mistake is
+ * the event itself. Somebody has to be able to take a row back out.
+ */
+ removeEventAttendance: isScanner
+ .input(
+ z.object({
+ hackathonId: z.string().uuid("Invalid hackathon ID"),
+ eventId: z.string().uuid("Invalid event ID"),
+ participantId: z.string().uuid("Invalid participant ID"),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const event = await db.query.hackathonEvents.findFirst({
+ where: and(
+ eq(hackathonEvents.id, input.eventId),
+ eq(hackathonEvents.hackathonId, input.hackathonId),
+ ),
+ columns: { id: true },
+ });
+
+ if (!event) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Event not found." });
+ }
+
+ // RETURNING rather than a preceding existence check: it names the row
+ // this statement removed, so a scan already undone by another organiser
+ // reads as "nothing to undo" instead of a second success.
+ const deleted = await db
+ .delete(hackathonEventAttendees)
+ .where(
+ and(
+ eq(hackathonEventAttendees.eventId, input.eventId),
+ eq(hackathonEventAttendees.participantId, input.participantId),
+ ),
+ )
+ .returning({ id: hackathonEventAttendees.id });
+
+ if (deleted.length === 0) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "That participant is not checked into this event.",
+ });
+ }
+
+ // Volunteers can reach this, so it is the widest-held destructive action
+ // in the product — worth a record of who undid which scan.
+ await recordAdminAction(db, {
+ userId: ctx.userId,
+ action: "hackathon.removeEventAttendance",
+ resourceId: input.participantId,
+ severity: "warn",
+ metadata: {
+ eventId: input.eventId,
+ hackathonId: input.hackathonId,
+ },
+ });
+
+ ctx.cache.delete(`hackathon:${input.hackathonId}:events`);
+
+ return { success: true };
+ }),
- scanParticipantPass: isAdmin
+ scanParticipantPass: isScanner
.input(
z.object({
hackathonId: z.string().uuid("Invalid hackathon ID"),
@@ -440,8 +806,10 @@ export const hackathonAdminRouter = createTRPCRouter({
throw error;
}
- // Invalidate hackathon caches after attendance scan
- ctx.cache.deletePattern("hackathon*");
+ // A scan changes one event's attendee count and nothing else. This runs
+ // at every door station all weekend, so it must not touch the roster or
+ // any attendee's cached registrations.
+ ctx.cache.delete(`hackathon:${input.hackathonId}:events`);
return {
success: true,
diff --git a/packages/api/src/routers/hackathon/announce.ts b/packages/api/src/routers/hackathon/announce.ts
new file mode 100644
index 00000000..ec9813a0
--- /dev/null
+++ b/packages/api/src/routers/hackathon/announce.ts
@@ -0,0 +1,201 @@
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { and, eq, inArray, isNotNull } from "drizzle-orm";
+import {
+ hackathonInterest,
+ hackathonParticipants,
+ hackathons,
+ users,
+} from "@query/db";
+import type { DrizzleDB } from "@query/db";
+import { createTRPCRouter } from "../../trpc";
+import { isAdmin } from "../../middleware/procedures";
+
+/**
+ * Mass announcements: "registration is open", "the schedule is live",
+ * "results are up".
+ *
+ * Kept separate from sendMassAcceptanceEmails because the two differ in the
+ * thing that matters — an acceptance also changes a participant's status and
+ * must be exactly once, while an announcement writes nothing and is safe to
+ * repeat. Sharing one procedure would have meant one set of guarantees serving
+ * two jobs badly.
+ */
+
+/** Recipients per request. See MASS_EMAIL_BATCH on the client: each one is an
+ * SMTP round trip, and a request carrying more does not finish inside Cloud
+ * Run's timeout. */
+const MAX_RECIPIENTS_PER_CALL = 500;
+
+const AUDIENCES = [
+ "interested",
+ "registered",
+ "approved",
+ "checked_in",
+] as const;
+
+type Audience = (typeof AUDIENCES)[number];
+
+/**
+ * Everyone in the chosen audience, as `{ userId, email }`.
+ *
+ * Email is read from the users table rather than stored alongside the interest
+ * or participant row, so a person who changes their address gets the mail at
+ * the address they actually use.
+ */
+const resolveAudience = async (
+ db: DrizzleDB,
+ hackathonId: string,
+ audience: Audience,
+) => {
+ if (audience === "interested") {
+ const rows = await db
+ .select({ userId: hackathonInterest.userId, email: users.email })
+ .from(hackathonInterest)
+ .innerJoin(users, eq(users.id, hackathonInterest.userId))
+ .where(
+ and(
+ eq(hackathonInterest.hackathonId, hackathonId),
+ isNotNull(users.email),
+ ),
+ );
+ return rows;
+ }
+
+ // "registered" is everyone holding a seat, whatever stage they are at.
+ // Rejected and waitlisted applicants are deliberately excluded from all
+ // three: nothing here is the right channel for telling somebody they are
+ // out, and a "see you this weekend" to a rejected applicant is worse than
+ // no email at all.
+ const statuses =
+ audience === "registered"
+ ? (["pending", "approved", "checked_in"] as const)
+ : ([audience] as const);
+
+ return await db
+ .select({ userId: hackathonParticipants.userId, email: users.email })
+ .from(hackathonParticipants)
+ .innerJoin(users, eq(users.id, hackathonParticipants.userId))
+ .where(
+ and(
+ eq(hackathonParticipants.hackathonId, hackathonId),
+ inArray(hackathonParticipants.registrationStatus, [...statuses]),
+ isNotNull(users.email),
+ ),
+ );
+};
+
+export const hackathonAnnounceRouter = createTRPCRouter({
+ /** How many people each audience would reach, so the compose screen can say
+ * so before anything is sent. */
+ audienceCounts: isAdmin
+ .input(z.object({ hackathonId: z.string().uuid() }))
+ .query(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const entries = await Promise.all(
+ AUDIENCES.map(async (audience) => {
+ const rows = await resolveAudience(db, input.hackathonId, audience);
+ return [audience, rows.length] as const;
+ }),
+ );
+
+ return Object.fromEntries(entries) as Record;
+ }),
+
+ sendAnnouncement: isAdmin
+ .input(
+ z.object({
+ hackathonId: z.string().uuid(),
+ audience: z.enum(AUDIENCES),
+ subject: z.string().trim().min(1).max(200),
+ heading: z.string().trim().min(1).max(200),
+ body: z.string().trim().min(1).max(5000),
+ ctaLabel: z.string().trim().max(60).optional(),
+ ctaUrl: z.string().url().max(500).optional(),
+ /** Skip this many recipients. The client walks the audience in batches
+ * and reports progress; the server stays one bounded unit of work. */
+ offset: z.number().int().min(0).default(0),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const hackathon = await db.query.hackathons.findFirst({
+ where: eq(hackathons.id, input.hackathonId),
+ columns: { id: true, name: true },
+ });
+
+ if (!hackathon) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Hackathon not found",
+ });
+ }
+
+ // A CTA label without a target renders a dead button, and a target
+ // without a label renders nothing at all — neither is what the organiser
+ // meant, and both are only visible once it is in someone's inbox.
+ if (!!input.ctaLabel !== !!input.ctaUrl) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "A button needs both a label and a link, or neither.",
+ });
+ }
+
+ const all = await resolveAudience(db, input.hackathonId, input.audience);
+
+ // Deduplicated: somebody on the interest list who later registered would
+ // otherwise be counted, and mailed, twice.
+ const seen = new Set();
+ const recipients = all.filter((row) => {
+ if (!row.email || seen.has(row.email)) return false;
+ seen.add(row.email);
+ return true;
+ });
+
+ const batch = recipients.slice(
+ input.offset,
+ input.offset + MAX_RECIPIENTS_PER_CALL,
+ );
+
+ const { sendAnnouncementEmail } = await import("@query/auth/email");
+
+ let sent = 0;
+ const failed: string[] = [];
+
+ for (const recipient of batch) {
+ if (!recipient.email) continue;
+ try {
+ await sendAnnouncementEmail({
+ email: recipient.email,
+ subject: input.subject,
+ heading: input.heading,
+ body: input.body,
+ ctaLabel: input.ctaLabel,
+ ctaUrl: input.ctaUrl,
+ });
+ sent++;
+ } catch (error) {
+ failed.push(recipient.email);
+ // Deliberate server-side operational logging: this is the only
+ // record of which address the provider rejected.
+ // eslint-disable-next-line no-console
+ console.error(
+ `[Email Service] Announcement failed for ${recipient.email}:`,
+ error,
+ );
+ }
+ }
+
+ const nextOffset = input.offset + batch.length;
+
+ return {
+ sent,
+ failed,
+ totalRecipients: recipients.length,
+ nextOffset,
+ done: nextOffset >= recipients.length,
+ };
+ }),
+});
diff --git a/packages/api/src/routers/hackathon/content.ts b/packages/api/src/routers/hackathon/content.ts
index 833b358d..8b31eda8 100644
--- a/packages/api/src/routers/hackathon/content.ts
+++ b/packages/api/src/routers/hackathon/content.ts
@@ -1,12 +1,16 @@
import { z } from "zod";
+import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
import {
hackathonParticipants,
hackathonProjects,
- hackathonTeams,
+ hackathonResults,
+ judgingProjects,
} from "@query/db";
-import { eq, and, inArray } from "drizzle-orm";
-import { callerIsAdmin } from "../../middleware/procedures";
+import { eq, and, inArray, isNotNull } from "drizzle-orm";
+import { callerIsAdmin, isAdmin } from "../../middleware/procedures";
+import { recordAdminAction } from "../../middleware/audit";
+import { assertHackathonVisible } from "./visibility";
import type { DrizzleDB } from "@query/db";
// Same visibility rule as getPublicProjects: a project only becomes public once
@@ -15,40 +19,178 @@ const PUBLIC_PROJECT_STATUSES: (typeof hackathonProjects.$inferSelect)["status"]
["submitted", "judging", "winner"];
export const hackathonContentRouter = createTRPCRouter({
- getTeams: publicProcedure
+ /**
+ * Fixes a submitted project on a team's behalf.
+ *
+ * team.submitProject refuses every edit once the submission window closes,
+ * and withdrawProject tells participants to "ask an organiser" about a
+ * project already in judging — which, until this existed, was advice nobody
+ * could act on. A dead demo link found during judging had no remedy.
+ *
+ * Deliberately narrow: the links and the copy, not the tracks. Tracks decide
+ * which judges a project reaches, and changing that mid-judging would
+ * silently rewrite who was supposed to have scored it.
+ */
+ adminUpdateProject: isAdmin
+ .input(
+ z.object({
+ projectId: z.string().uuid(),
+ name: z.string().min(1).max(255).optional(),
+ description: z.string().min(1).max(5000).optional(),
+ githubUrl: z.string().url().max(500).nullable().optional(),
+ demoUrl: z.string().url().max(500).nullable().optional(),
+ videoUrl: z.string().url().max(500).nullable().optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { projectId, ...updateData } = input;
+ const db = ctx.db as DrizzleDB;
+
+ const existing = await db.query.hackathonProjects.findFirst({
+ where: eq(hackathonProjects.id, projectId),
+ columns: { id: true, hackathonId: true },
+ });
+
+ if (!existing) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Project not found",
+ });
+ }
+
+ const [updated] = await db
+ .update(hackathonProjects)
+ .set({ ...updateData, updatedAt: new Date() })
+ .where(eq(hackathonProjects.id, projectId))
+ .returning();
+
+ ctx.cache.delete(`hackathon:${existing.hackathonId}:projects`);
+ ctx.cache.deletePattern(
+ `hackathon:${existing.hackathonId}:public-projects*`,
+ );
+
+ return updated;
+ }),
+
+ /**
+ * Pulls a submission out of the event.
+ *
+ * The participant-facing path refuses this once judging holds the project;
+ * an organiser has to be able to do it anyway — a plagiarised or
+ * rule-breaking entry is exactly the case that arises after judging starts.
+ */
+ adminWithdrawProject: isAdmin
+ .input(
+ z.object({
+ projectId: z.string().uuid(),
+ /** Withdraw even though judges have already scored it. Their votes
+ * stay on the record; the project simply stops being eligible. */
+ force: z.boolean().default(false),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const existing = await db.query.hackathonProjects.findFirst({
+ where: eq(hackathonProjects.id, input.projectId),
+ columns: { id: true, hackathonId: true, status: true },
+ });
+
+ if (!existing) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Project not found",
+ });
+ }
+
+ if (existing.status === "judging" && !input.force) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message:
+ "Judges are already scoring this project. Withdrawing it removes it from the results — confirm to continue.",
+ });
+ }
+
+ await db
+ .update(hackathonProjects)
+ .set({ status: "draft", submittedAt: null, updatedAt: new Date() })
+ .where(eq(hackathonProjects.id, input.projectId));
+
+ // 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 computed and published as a placing.
+ await db
+ .update(judgingProjects)
+ .set({ withdrawnAt: new Date() })
+ .where(eq(judgingProjects.sourceProjectId, input.projectId));
+
+ await recordAdminAction(db, {
+ userId: ctx.userId,
+ action: "hackathon.adminWithdrawProject",
+ resourceId: input.projectId,
+ // Pulling a project judges are actively scoring changes the results.
+ severity: existing.status === "judging" ? "critical" : "warn",
+ metadata: {
+ hackathonId: existing.hackathonId,
+ previousStatus: existing.status,
+ forced: input.force,
+ },
+ });
+
+ ctx.cache.delete(`hackathon:${existing.hackathonId}:projects`);
+ ctx.cache.deletePattern(
+ `hackathon:${existing.hackathonId}:public-projects*`,
+ );
+
+ return { success: true };
+ }),
+
+ /**
+ * The published placings, for everyone.
+ *
+ * Reads only rows with publishedAt set, so a computed-but-unreviewed draft
+ * is invisible until an organiser releases it. Unpublishing takes it back
+ * down — the announcement is reversible rather than a one-way door.
+ */
+ getResults: publicProcedure
.input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") }))
.query(async ({ ctx, input }) => {
- const teams = await (ctx.db as DrizzleDB).query.hackathonTeams.findMany({
- where: eq(hackathonTeams.hackathonId, input.hackathonId),
- with: {
- captain: {
- columns: { id: true, name: true, image: true },
- },
- participants: {
- // Team rosters are public, so they carry neither the decision made
- // on each application — registrationStatus names everyone who was
- // rejected or waitlisted — nor a participant id, which is the
- // entire content of that participant's event pass QR.
- columns: {
- userId: true,
+ await assertHackathonVisible(ctx, input.hackathonId);
+
+ const cacheKey = `hackathon:${input.hackathonId}:results`;
+
+ const fetchResults = () =>
+ (ctx.db as DrizzleDB).query.hackathonResults.findMany({
+ where: and(
+ eq(hackathonResults.hackathonId, input.hackathonId),
+ isNotNull(hackathonResults.publishedAt),
+ ),
+ with: {
+ project: {
+ columns: { id: true, name: true, teamMembers: true },
},
- with: {
- user: {
- columns: { id: true, name: true, image: true },
- },
+ sourceProject: {
+ columns: { id: true, name: true, githubUrl: true, demoUrl: true },
+ with: { team: { columns: { id: true, name: true } } },
},
},
- },
- orderBy: (hackathonTeams, { desc }) => [desc(hackathonTeams.createdAt)],
- });
+ orderBy: (results, { asc }) => [asc(results.placement)],
+ });
- return teams;
- }),
+ const cached =
+ ctx.cache.get>>(cacheKey);
+ if (cached !== null) return cached;
+ const results = await fetchResults();
+ ctx.cache.set(cacheKey, results, 60);
+ return results;
+ }),
projects: publicProcedure
.input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") }))
.query(async ({ ctx, input }) => {
+ await assertHackathonVisible(ctx, input.hackathonId);
+
const fetchProjects = () =>
(ctx.db as DrizzleDB).query.hackathonProjects.findMany({
where: eq(hackathonProjects.hackathonId, input.hackathonId),
@@ -124,30 +266,58 @@ export const hackathonContentRouter = createTRPCRouter({
}),
+ /**
+ * The public project gallery.
+ *
+ * Anonymous, and read by most of the venue at once when demos open — so it
+ * is both bounded and cached. Uncached and unbounded it was a full table
+ * read with a team join per request, at the busiest moment of the event.
+ */
getPublicProjects: publicProcedure
- .input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") }))
+ .input(
+ z.object({
+ hackathonId: z.string().uuid("Invalid hackathon ID"),
+ limit: z.number().int().min(1).max(200).default(100),
+ offset: z.number().int().min(0).default(0),
+ }),
+ )
.query(async ({ ctx, input }) => {
- const projects = await (
- ctx.db as DrizzleDB
- ).query.hackathonProjects.findMany({
- where: and(
- eq(hackathonProjects.hackathonId, input.hackathonId),
- // We only show projects that are submitted, judging, or winner. Drafts stay hidden.
- inArray(hackathonProjects.status, ["submitted", "judging", "winner"]),
- ),
- // Same rule as `projects` above: submittedById is the participant id
- // behind that person's event pass QR, and this endpoint is anonymous.
- columns: { submittedById: false },
- with: {
- team: {
- columns: {
- id: true,
- name: true,
+ await assertHackathonVisible(ctx, input.hackathonId);
+
+ const cacheKey = `hackathon:${input.hackathonId}:public-projects:${input.limit}:${input.offset}`;
+
+ const fetchPage = () =>
+ (ctx.db as DrizzleDB).query.hackathonProjects.findMany({
+ where: and(
+ eq(hackathonProjects.hackathonId, input.hackathonId),
+ // We only show projects that are submitted, judging, or winner. Drafts stay hidden.
+ inArray(hackathonProjects.status, [
+ ...PUBLIC_PROJECT_STATUSES,
+ ]),
+ ),
+ // Same rule as `projects` above: submittedById is the participant id
+ // behind that person's event pass QR, and this endpoint is anonymous.
+ columns: { submittedById: false },
+ with: {
+ team: {
+ columns: {
+ id: true,
+ name: true,
+ },
},
},
- },
- orderBy: (projects, { desc }) => [desc(projects.submittedAt)],
- });
+ orderBy: (projects, { desc }) => [desc(projects.submittedAt)],
+ limit: input.limit,
+ offset: input.offset,
+ });
+
+ const cached = ctx.cache.get>>(
+ cacheKey,
+ );
+ if (cached !== null) return cached;
+
+ const projects = await fetchPage();
+ ctx.cache.set(cacheKey, projects, 60);
return projects;
}),
});
diff --git a/packages/api/src/routers/hackathon/crud.ts b/packages/api/src/routers/hackathon/crud.ts
index 432ae25b..b1310d3d 100644
--- a/packages/api/src/routers/hackathon/crud.ts
+++ b/packages/api/src/routers/hackathon/crud.ts
@@ -3,7 +3,16 @@ import { TRPCError } from "@trpc/server";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { hackathons } from "@query/db";
import { eq, and, gte, notInArray } from "drizzle-orm";
-import { callerIsAdmin, isAdmin } from "../../middleware/procedures";
+import {
+ callerIsAdmin,
+ isAdmin,
+ isSuperAdmin,
+} from "../../middleware/procedures";
+import {
+ isForeignKeyViolation,
+ isUniqueViolation,
+} from "../../middleware/db-errors";
+import { recordAdminAction } from "../../middleware/audit";
import { CacheKeys, VOLATILE_TTL } from "../../middleware/cache";
import type { DrizzleDB } from "@query/db";
@@ -233,12 +242,26 @@ export const hackathonCrudRouter = createTRPCRouter({
),
)
.mutation(async ({ ctx, input }) => {
- const [newHackathon] = await (ctx.db as DrizzleDB)
- .insert(hackathons)
- .values({
- ...input,
- })
- .returning();
+ let newHackathon;
+ try {
+ [newHackathon] = await (ctx.db as DrizzleDB)
+ .insert(hackathons)
+ .values({
+ ...input,
+ })
+ .returning();
+ } catch (error) {
+ // unique_hackathon_name. Admin URLs are built from the name, so a
+ // duplicate would make one of the two unreachable — worth saying
+ // plainly rather than surfacing a driver error.
+ if (isUniqueViolation(error)) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: `A hackathon named "${input.name}" already exists. Names are used in admin links, so they have to be distinct.`,
+ });
+ }
+ throw error;
+ }
ctx.cache.deletePattern("hackathons:*");
@@ -269,6 +292,10 @@ export const hackathonCrudRouter = createTRPCRouter({
"cancelled",
])
.optional(),
+ // These five are nullable as well as optional, and the distinction is
+ // load-bearing: `undefined` means "leave unchanged", `null` means
+ // "clear it". Optional alone gave the edit form no way to empty a
+ // field it had already filled — sending `[]` reads as unchanged.
prizes: z
.array(
z.object({
@@ -278,12 +305,15 @@ export const hackathonCrudRouter = createTRPCRouter({
}),
)
.max(20)
+ .nullable()
.optional(),
- rules: z.string().max(10000).optional(),
+ rules: z.string().max(10000).nullable().optional(),
theme: z.string().max(200).optional(),
- tracks: z.array(z.string().max(100)).max(50).optional(),
- challenges: z.array(z.string().max(100)).max(50).optional(),
- websiteUrl: z.string().url().max(500).optional(),
+ tracks: z.array(z.string().max(100)).max(50).nullable().optional(),
+ challenges: z.array(z.string().max(100)).max(50).nullable().optional(),
+ // No empty-string escape hatch: "" would be stored and render as a
+ // link to nowhere. Clearing the field sends null.
+ websiteUrl: z.string().url().max(500).nullable().optional(),
isPublic: z.boolean().optional(),
}),
)
@@ -331,14 +361,25 @@ export const hackathonCrudRouter = createTRPCRouter({
});
}
- const [updatedHackathon] = await (ctx.db as DrizzleDB)
- .update(hackathons)
- .set({
- ...updateData,
- updatedAt: new Date(),
- })
- .where(eq(hackathons.id, id))
- .returning();
+ let updatedHackathon;
+ try {
+ [updatedHackathon] = await (ctx.db as DrizzleDB)
+ .update(hackathons)
+ .set({
+ ...updateData,
+ updatedAt: new Date(),
+ })
+ .where(eq(hackathons.id, id))
+ .returning();
+ } catch (error) {
+ if (isUniqueViolation(error)) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: `Another hackathon is already named "${updateData.name}". Names are used in admin links, so they have to be distinct.`,
+ });
+ }
+ throw error;
+ }
ctx.cache.delete(CacheKeys.hackathon(id));
ctx.cache.deletePattern("hackathons:*");
@@ -347,20 +388,71 @@ export const hackathonCrudRouter = createTRPCRouter({
}),
- delete: isAdmin
- .input(z.object({ hackathonId: z.string().uuid() }))
+ /**
+ * Super-admin only.
+ *
+ * isAdmin never checks `role`, so the default "admin" and "moderator" both
+ * passed — every staff account could destroy an edition. Verified three
+ * active super_admin rows exist before narrowing this, because a gate with
+ * nobody behind it is an outage rather than a control.
+ */
+ delete: isSuperAdmin
+ .input(
+ z.object({
+ hackathonId: z.string().uuid(),
+ // The hackathon's own name, typed by the caller. Eleven tables cascade
+ // off this row — every participant, team, project and judge vote for
+ // the event. A browser confirm() is one misplaced click; this is not.
+ confirmName: z.string().min(1),
+ }),
+ )
.mutation(async ({ ctx, input }) => {
- const { hackathonId } = input;
+ const { hackathonId, confirmName } = input;
+
+ const existing = await (ctx.db as DrizzleDB).query.hackathons.findFirst({
+ where: eq(hackathons.id, hackathonId),
+ columns: { id: true, name: true },
+ });
+
+ if (!existing) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Hackathon not found",
+ });
+ }
+
+ if (confirmName.trim() !== existing.name.trim()) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Type the hackathon's exact name to confirm. Expected "${existing.name}".`,
+ });
+ }
// Every child table cascades off this row, so reporting success for an id
// that matched nothing hides a delete that never happened. RETURNING names
// the rows the statement itself removed, which a separate existence check
// cannot: that only describes the row as it was before the DELETE, and a
// concurrent delete landing in between would still be called a success.
- const deleted = await (ctx.db as DrizzleDB)
- .delete(hackathons)
- .where(eq(hackathons.id, hackathonId))
- .returning({ id: hackathons.id });
+ let deleted;
+ try {
+ deleted = await (ctx.db as DrizzleDB)
+ .delete(hackathons)
+ .where(eq(hackathons.id, hackathonId))
+ .returning({ id: hackathons.id });
+ } catch (error) {
+ // member.hackathon_id is ON DELETE RESTRICT, so this fires when paid
+ // club memberships still hang off the edition. That is the guard
+ // working, not a bug — those rows are the only record of who paid and
+ // nothing re-creates them.
+ if (isForeignKeyViolation(error)) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message:
+ "This hackathon still has club memberships attached. Those are paid records and cannot be cascaded away — move or remove them deliberately first.",
+ });
+ }
+ throw error;
+ }
if (deleted?.length === 0) {
throw new TRPCError({
@@ -369,6 +461,15 @@ export const hackathonCrudRouter = createTRPCRouter({
});
}
+ await recordAdminAction(ctx.db as DrizzleDB, {
+ userId: ctx.userId,
+ action: "hackathon.delete",
+ resourceId: hackathonId,
+ severity: "critical",
+ // The name is recorded because the row it came from no longer exists.
+ metadata: { name: existing.name },
+ });
+
ctx.cache.delete(CacheKeys.hackathon(hackathonId));
ctx.cache.deletePattern("hackathons:*");
return { success: true };
diff --git a/packages/api/src/routers/hackathon/events.ts b/packages/api/src/routers/hackathon/events.ts
index 814af0c5..49699004 100644
--- a/packages/api/src/routers/hackathon/events.ts
+++ b/packages/api/src/routers/hackathon/events.ts
@@ -4,9 +4,12 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
import {
hackathons,
hackathonEvents,
+ hackathonEventAttendees,
} from "@query/db";
-import { eq } from "drizzle-orm";
+import { eq, inArray, sql } from "drizzle-orm";
import { isAdmin } from "../../middleware/procedures";
+import { recordAdminAction } from "../../middleware/audit";
+import { assertHackathonVisible } from "./visibility";
import type { DrizzleDB } from "@query/db";
export const hackathonEventsRouter = createTRPCRouter({
@@ -53,13 +56,13 @@ export const hackathonEventsRouter = createTRPCRouter({
description: input.description,
type: input.type,
location: input.location,
+ points: input.points,
startTime: input.startTime,
endTime: input.endTime,
- points: input.points,
})
.returning();
- ctx.cache.deletePattern("hackathon*");
+ ctx.cache.delete(`hackathon:${input.hackathonId}:events`);
return newEvent;
}),
@@ -113,7 +116,9 @@ export const hackathonEventsRouter = createTRPCRouter({
.where(eq(hackathonEvents.id, eventId))
.returning();
- ctx.cache.deletePattern("hackathon*");
+ // The schedule for this edition, and nothing else. The old blanket
+ // pattern also matched every attendee's cached registrations.
+ ctx.cache.delete(`hackathon:${existing.hackathonId}:events`);
return updatedEvent;
}),
@@ -123,12 +128,15 @@ export const hackathonEventsRouter = createTRPCRouter({
.input(
z.object({
eventId: z.string().uuid("Invalid event ID"),
+ /** Delete even though people have already scanned in. Their check-in
+ * rows go with it — there is no undo and no export first. */
+ force: z.boolean().default(false),
}),
)
.mutation(async ({ ctx, input }) => {
- const existing = await (
- ctx.db as DrizzleDB
- ).query.hackathonEvents.findFirst({
+ const db = ctx.db as DrizzleDB;
+
+ const existing = await db.query.hackathonEvents.findFirst({
where: eq(hackathonEvents.id, input.eventId),
});
@@ -136,37 +144,91 @@ export const hackathonEventsRouter = createTRPCRouter({
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
}
- await (ctx.db as DrizzleDB)
+ // hackathon_event_attendee cascades off this row. At a keynote that is
+ // every badge scanned at the door — thousands of rows, gone on one
+ // click, with nothing that can rebuild them.
+ const [scans] = await db
+ .select({ count: sql`count(*)::int` })
+ .from(hackathonEventAttendees)
+ .where(eq(hackathonEventAttendees.eventId, input.eventId));
+
+ const checkIns = scans?.count ?? 0;
+
+ if (checkIns > 0 && !input.force) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: `${checkIns} person(s) have already checked into "${existing.name}". Deleting the event erases those check-ins permanently.`,
+ });
+ }
+
+ await db
.delete(hackathonEvents)
.where(eq(hackathonEvents.id, input.eventId));
- ctx.cache.deletePattern("hackathon*");
+ await recordAdminAction(db, {
+ userId: ctx.userId,
+ action: "hackathon.deleteEvent",
+ resourceId: input.eventId,
+ // Forcing past the refusal destroys check-in records with no undo.
+ severity: checkIns > 0 ? "critical" : "info",
+ metadata: {
+ name: existing.name,
+ hackathonId: existing.hackathonId,
+ deletedCheckIns: checkIns,
+ forced: input.force,
+ },
+ });
+
+ ctx.cache.delete(`hackathon:${existing.hackathonId}:events`);
- return { success: true };
+ return { success: true, deletedCheckIns: checkIns };
}),
getEvents: publicProcedure
.input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") }))
.query(async ({ ctx, input }) => {
+ // A draft edition's schedule is not public just because its uuid leaked.
+ await assertHackathonVisible(ctx, input.hackathonId);
+
const cacheKey = `hackathon:${input.hackathonId}:events`;
const fetchEvents = async () => {
- const eventsData = await (
- ctx.db as DrizzleDB
- ).query.hackathonEvents.findMany({
+ const db = ctx.db as DrizzleDB;
+
+ const eventsData = await db.query.hackathonEvents.findMany({
where: eq(hackathonEvents.hackathonId, input.hackathonId),
orderBy: (events, { asc }) => [asc(events.startTime)],
- with: {
- attendees: {
- columns: { id: true },
- },
- },
});
+ if (eventsData.length === 0) return [];
+
+ // Counted in the database rather than by loading the rows. This is the
+ // schedule every attendee's phone polls: eagerly joining attendees to
+ // produce a handful of integers meant ~15 events x 2000 people, and it
+ // shipped the whole array over the wire on the way back.
+ const counts = await db
+ .select({
+ eventId: hackathonEventAttendees.eventId,
+ count: sql`count(*)::int`,
+ })
+ .from(hackathonEventAttendees)
+ .where(
+ inArray(
+ hackathonEventAttendees.eventId,
+ eventsData.map((event) => event.id),
+ ),
+ )
+ .groupBy(hackathonEventAttendees.eventId);
+
+ const countByEvent = new Map(
+ counts.map((row) => [row.eventId, row.count]),
+ );
+
return eventsData.map((e) => ({
...e,
- attendeeCount: e.attendees.length,
+ // An event nobody has scanned into produces no group, not a zero row.
+ attendeeCount: countByEvent.get(e.id) ?? 0,
}));
};
diff --git a/packages/api/src/routers/hackathon/index.ts b/packages/api/src/routers/hackathon/index.ts
index e25f6af8..ebc1aec2 100644
--- a/packages/api/src/routers/hackathon/index.ts
+++ b/packages/api/src/routers/hackathon/index.ts
@@ -5,6 +5,7 @@ import { hackathonAdminRouter } from "./admin";
import { hackathonEventsRouter } from "./events";
import { hackathonContentRouter } from "./content";
import { hackathonInterestRouter } from "./interest";
+import { hackathonAnnounceRouter } from "./announce";
export const hackathonRouter = mergeRouters(
hackathonCrudRouter,
@@ -13,4 +14,5 @@ export const hackathonRouter = mergeRouters(
hackathonEventsRouter,
hackathonContentRouter,
hackathonInterestRouter,
+ hackathonAnnounceRouter,
);
diff --git a/packages/api/src/routers/hackathon/registration.ts b/packages/api/src/routers/hackathon/registration.ts
index 28ebca48..a45f65e1 100644
--- a/packages/api/src/routers/hackathon/registration.ts
+++ b/packages/api/src/routers/hackathon/registration.ts
@@ -149,11 +149,11 @@ export const hackathonRegistrationRouter = createTRPCRouter({
});
}
+ // 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.
const member = await tx.query.members.findFirst({
- where: and(
- eq(members.userId, ctx.userId as string),
- eq(members.hackathonId, input.hackathonId),
- ),
+ where: eq(members.userId, ctx.userId as string),
});
/**
diff --git a/packages/api/src/routers/hackathon/visibility.ts b/packages/api/src/routers/hackathon/visibility.ts
new file mode 100644
index 00000000..6a174c65
--- /dev/null
+++ b/packages/api/src/routers/hackathon/visibility.ts
@@ -0,0 +1,44 @@
+import { TRPCError } from "@trpc/server";
+import { hackathons } from "@query/db";
+import { eq } from "drizzle-orm";
+import type { DrizzleDB } from "@query/db";
+import { callerIsAdmin } from "../../middleware/procedures";
+import type { Context } from "../../context";
+
+/**
+ * Statuses only staff may see. A draft edition is one nobody outside the team
+ * is meant to know exists yet.
+ */
+export const STAFF_ONLY_STATUSES: (typeof hackathons.$inferSelect)["status"][] =
+ ["draft"];
+
+/**
+ * Refuses to serve anything belonging to a hackathon the caller cannot see.
+ *
+ * `getById` enforced this on the hackathon row itself, but its public children
+ * — the schedule, the project gallery, the results — each queried by
+ * hackathonId with no such check. Anyone holding the uuid could read an
+ * unannounced edition's full timetable and submissions, which is exactly the
+ * shape of leak that a "draft" status exists to prevent.
+ *
+ * NOT_FOUND rather than FORBIDDEN on purpose: telling an anonymous caller that
+ * a hidden edition exists is most of the leak.
+ */
+export const assertHackathonVisible = async (
+ ctx: Context,
+ hackathonId: string,
+) => {
+ const row = await (ctx.db as DrizzleDB).query.hackathons.findFirst({
+ where: eq(hackathons.id, hackathonId),
+ columns: { id: true, status: true },
+ });
+
+ if (!row) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Hackathon not found" });
+ }
+
+ if (!STAFF_ONLY_STATUSES.includes(row.status)) return;
+ if (await callerIsAdmin(ctx)) return;
+
+ throw new TRPCError({ code: "NOT_FOUND", message: "Hackathon not found" });
+};
diff --git a/packages/api/src/routers/initiative.ts b/packages/api/src/routers/initiative.ts
index 3d88f7b4..c1fa6875 100644
--- a/packages/api/src/routers/initiative.ts
+++ b/packages/api/src/routers/initiative.ts
@@ -12,7 +12,6 @@ import type { DrizzleDB, Initiative } from "@query/db";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { isAdmin, isProjectLeader } from "../middleware/procedures";
import { clearProjectLeaderCaches } from "../middleware/cache";
-import { resolveHackathonId } from "../services/portal-context";
const notFound = (message = "Initiative not found") =>
new TRPCError({ code: "NOT_FOUND", message });
@@ -76,17 +75,13 @@ function canManage(
* live membership to check, which refuses rather than waving everyone through.
*/
async function requireActiveMember(db: Reader, userId: string) {
- const hackathonId = await resolveHackathonId(db as DrizzleDB);
-
- const member = hackathonId
- ? await db.query.members.findFirst({
- where: and(
- eq(members.userId, userId),
- eq(members.hackathonId, hackathonId),
- ),
- columns: { isActive: true, membershipEndDate: true },
- })
- : undefined;
+ // Initiatives were deliberately un-scoped from hackathons; membership now is
+ // too. This previously resolved a current edition and refused everyone when
+ // none existed, which is how the club half went dead outside event season.
+ const member = await db.query.members.findFirst({
+ where: eq(members.userId, userId),
+ columns: { isActive: true, membershipEndDate: true },
+ });
const active = !!(
member?.isActive &&
@@ -444,7 +439,8 @@ export const initiativeRouter = createTRPCRouter({
}
// Two leaders clicking the same button: the second is a no-op, so
- // decidedAt keeps pointing at the real decision.
+ // decidedAt keeps pointing at the real decision — and no second email
+ // goes out, because there is no second decision.
if (application.status === input.decision) {
return { status: application.status };
}
diff --git a/packages/api/src/routers/judge/admin.ts b/packages/api/src/routers/judge/admin.ts
index 314f2ae3..1bb3279a 100644
--- a/packages/api/src/routers/judge/admin.ts
+++ b/packages/api/src/routers/judge/admin.ts
@@ -7,17 +7,21 @@ import {
judgeVotes,
judgingProjects,
judgeQueue,
- hackathonMaps,
hackathons,
+ hackathonProjects,
users,
hackathonParticipants,
} from "@query/db";
-import { eq, and, asc, sql } from "drizzle-orm";
+import { eq, and, asc, sql, inArray } from "drizzle-orm";
import { isAdmin } from "../../middleware/procedures";
import { CacheKeys } from "../../middleware/cache";
import type { DrizzleDB } from "@query/db";
import { shuffleArray, buildCoverageQueues } from "./helpers";
+/** Rows per queue INSERT. Well under the ~16k that Postgres's 65535-parameter
+ * ceiling allows at 4 bound parameters per row. */
+const QUEUE_INSERT_CHUNK = 5000;
+
export const judgeAdminRouter = createTRPCRouter({
list: isAdmin.query(async ({ ctx }) => {
const allJudges = await (ctx.db as DrizzleDB).query.judges.findMany({
@@ -193,238 +197,117 @@ export const judgeAdminRouter = createTRPCRouter({
return result[0];
}),
- createProject: isAdmin
- .input(
- z.object({
- hackathonId: z.string().uuid(),
- name: z.string().min(1).max(255),
- description: z.string().max(1000).optional(),
- tableNumber: z.number().min(1),
- zone: z.string().optional(),
- teamMembers: z.string().max(500).optional(),
- projectUrl: z.string().url().optional(),
- repoUrl: z.string().url().optional(),
- tracks: z.array(z.string()).optional(),
- challenges: z.array(z.string()).optional(),
- isCreateX: z.boolean().default(false),
- }),
- )
+ /**
+ * Turns submitted projects into judgeable ones.
+ *
+ * This is the only way a judging entry comes into existence. Teams submit
+ * through the portal, an organiser presses one button, and every submission
+ * gets a table number. Idempotent by design — run it again as late
+ * submissions land and only the new ones are added, because
+ * judging_project_source_unique pins one judgeable row per submission.
+ */
+ promoteSubmissions: isAdmin
+ .input(z.object({ hackathonId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
- const result = await (ctx.db as DrizzleDB)
- .insert(judgingProjects)
- .values(input)
- .returning();
+ return await (ctx.db as DrizzleDB).transaction(async (tx) => {
+ // Serializes concurrent promotions for this event, so two organisers
+ // pressing the button together cannot both read the same max table
+ // number and hand out duplicates.
+ await tx
+ .select({ id: hackathons.id })
+ .from(hackathons)
+ .where(eq(hackathons.id, input.hackathonId))
+ .for("update");
- return result[0];
- }),
+ const submissions = await tx.query.hackathonProjects.findMany({
+ where: and(
+ eq(hackathonProjects.hackathonId, input.hackathonId),
+ inArray(hackathonProjects.status, ["submitted", "judging"]),
+ ),
+ with: { team: { columns: { name: true } } },
+ orderBy: [asc(hackathonProjects.submittedAt)],
+ });
- bulkCreateProjects: isAdmin
- .input(
- z.object({
- hackathonId: z.string().uuid(),
- projects: z.array(
- z.object({
- name: z.string().min(1).max(255),
- description: z.string().max(1000).optional(),
- tableNumber: z.number().min(1),
- zone: z.string().optional(),
- category: z.string().max(100).optional(),
- teamMembers: z.string().max(500).optional(),
- tracks: z.array(z.string()).optional(),
- challenges: z.array(z.string()).optional(),
- isCreateX: z.boolean().default(false),
- }),
- ),
- }),
- )
- .mutation(async ({ ctx, input }) => {
- const result = await (ctx.db as DrizzleDB)
- .insert(judgingProjects)
- .values(
- input.projects.map((p) => ({
- ...p,
- hackathonId: input.hackathonId,
- })),
- )
- .returning();
+ if (submissions.length === 0) {
+ return {
+ created: 0,
+ alreadyPresent: 0,
+ total: 0,
+ queuesNeedRebuild: false,
+ };
+ }
- return result;
- }),
+ const existing = await tx.query.judgingProjects.findMany({
+ where: eq(judgingProjects.hackathonId, input.hackathonId),
+ columns: { id: true, sourceProjectId: true, tableNumber: true },
+ });
- /** Bulk import judges from a parsed CSV.
- * Creates user stubs for emails not yet in the system,
- * creates judge records, and assigns to the hackathon. */
- bulkImportJudges: isAdmin
- .input(
- z.object({
- hackathonId: z.string().uuid(),
- judges: z.array(
- z.object({
- name: z.string().min(1).max(255),
- email: z.string().email(),
- track: z.string().optional(),
- }),
- ),
- }),
- )
- .mutation(async ({ ctx, input }) => {
- return await (ctx.db as DrizzleDB).transaction(async (tx) => {
- const results = { created: 0, skipped: 0, errors: [] as string[] };
+ const promoted = new Set(
+ existing
+ .map((row) => row.sourceProjectId)
+ .filter((id): id is string => !!id),
+ );
- for (const j of input.judges) {
- try {
- // Only rows that actually gained a judge record or a hackathon
- // assignment count as imported.
- let imported = false;
+ const fresh = submissions.filter((s) => !promoted.has(s.id));
- // 1. Find or create user by email
- let user = await tx.query.users.findFirst({
- where: eq(users.email, j.email),
- });
+ let nextTable = existing.reduce(
+ (max, row) => Math.max(max, row.tableNumber),
+ 0,
+ );
- if (!user) {
- const id = crypto.randomUUID();
- const [newUser] = await tx
- .insert(users)
- .values({ id, name: j.name, email: j.email })
- .returning();
- user = newUser as NonNullable;
- }
-
- // 2. Find or create judge record for this hackathon
- let judge = await tx.query.judges.findFirst({
- where: and(
- eq(judges.userId, user.id),
- eq(judges.hackathonId, input.hackathonId),
- ),
- });
+ if (fresh.length > 0) {
+ await tx.insert(judgingProjects).values(
+ fresh.map((submission) => ({
+ hackathonId: input.hackathonId,
+ sourceProjectId: submission.id,
+ name: submission.name,
+ description: submission.description,
+ tableNumber: ++nextTable,
+ // hackathon_project.teamMembers is text[]; this column is a
+ // single text field. Joined, not assigned — handing an array
+ // straight over is a type error at best and "[object Object]"
+ // on a judge's screen at worst.
+ teamMembers:
+ submission.team?.name ??
+ (submission.teamMembers?.length
+ ? submission.teamMembers.join(", ")
+ : null),
+ projectUrl: submission.demoUrl,
+ repoUrl: submission.githubUrl,
+ tracks: submission.tracks?.length ? submission.tracks : null,
+ challenges: submission.challenges?.length
+ ? submission.challenges
+ : null,
+ isCreateX: submission.isCreateX ?? false,
+ })),
+ );
- if (!judge) {
- const [newJudge] = await tx
- .insert(judges)
- .values({
- userId: user.id,
- hackathonId: input.hackathonId,
- name: j.name,
- isActive: true,
- })
- .returning();
- judge = newJudge as NonNullable;
- imported = true;
- }
-
- // 3. Assign to hackathon (skip if already assigned)
- const existingAssignment =
- await tx.query.judgeAssignments.findFirst({
- where: and(
- eq(judgeAssignments.judgeId, judge.id),
- eq(judgeAssignments.hackathonId, input.hackathonId),
- ),
- });
-
- if (!existingAssignment) {
- await tx.insert(judgeAssignments).values({
- judgeId: judge.id,
- hackathonId: input.hackathonId,
- track: j.track || null,
- });
- imported = true;
- }
-
- if (imported) results.created++;
- else results.skipped++;
- } catch (e) {
- results.skipped++;
- results.errors.push(
- `${j.email}: ${e instanceof Error ? e.message : "Unknown error"}`,
+ await tx
+ .update(hackathonProjects)
+ .set({ status: "judging", updatedAt: new Date() })
+ .where(
+ inArray(
+ hackathonProjects.id,
+ fresh.map((submission) => submission.id),
+ ),
);
- }
}
- return results;
- });
- }),
-
- /** Bulk import projects from a parsed CSV.
- * Auto-assigns incrementing table numbers starting from 1. */
- bulkImportProjects: isAdmin
- .input(
- z.object({
- hackathonId: z.string().uuid(),
- projects: z.array(
- z.object({
- name: z.string().min(1).max(255),
- teamMembers: z.string().max(500).optional(),
- mainTrack: z.string().optional(),
- extraTracks: z.array(z.string()).optional(),
- isCreateX: z.boolean().default(false),
- }),
- ),
- }),
- )
- .mutation(async ({ ctx, input }) => {
- // An empty CSV would reach .values([]), which Drizzle rejects.
- // The table bounds stay numeric so this branch keeps the same response
- // shape as a real import — widening them to `undefined` breaks the
- // setup wizard's prop type and takes the whole site build down with it.
- if (input.projects.length === 0) {
- return { created: 0, startTable: 0, endTable: 0 };
- }
-
- // Get the current max table number for this hackathon
- const maxResult = await (ctx.db as DrizzleDB)
- .select({
- max: sql`COALESCE(MAX(${judgingProjects.tableNumber}), 0)`,
- })
- .from(judgingProjects)
- .where(eq(judgingProjects.hackathonId, input.hackathonId));
-
- let nextTable = (maxResult[0]?.max ?? 0) + 1;
-
- const rows = input.projects.map((p) => {
- const tracks = [
- ...(p.mainTrack ? [p.mainTrack] : []),
- ...(p.extraTracks || []),
- ].filter(Boolean);
+ // Queues are built from a snapshot of the project list. Anything
+ // promoted after assignment sits in nobody's queue and would simply
+ // never be judged, with nothing on screen to say so.
+ const [queued] = await tx
+ .select({ count: sql`count(*)::int` })
+ .from(judgeQueue)
+ .where(eq(judgeQueue.hackathonId, input.hackathonId));
return {
- hackathonId: input.hackathonId,
- name: p.name,
- teamMembers: p.teamMembers,
- tableNumber: nextTable++,
- tracks: tracks.length > 0 ? tracks : undefined,
- isCreateX: p.isCreateX,
+ created: fresh.length,
+ alreadyPresent: submissions.length - fresh.length,
+ total: submissions.length,
+ queuesNeedRebuild: fresh.length > 0 && (queued?.count ?? 0) > 0,
};
});
-
- const result = await (ctx.db as DrizzleDB)
- .insert(judgingProjects)
- .values(rows)
- .returning();
-
- return {
- created: result.length,
- startTable: rows[0]?.tableNumber,
- endTable: rows[rows.length - 1]?.tableNumber,
- };
- }),
-
- addMap: isAdmin
- .input(
- z.object({
- hackathonId: z.string().uuid(),
- imageUrl: z.string().url(),
- name: z.string().max(100).optional(),
- order: z.number().min(0).default(0),
- }),
- )
- .mutation(async ({ ctx, input }) => {
- const result = await (ctx.db as DrizzleDB)
- .insert(hackathonMaps)
- .values(input)
- .returning();
-
- return result[0];
}),
initializeQueue: isAdmin
@@ -436,6 +319,26 @@ export const judgeAdminRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
+ // A judges row belongs to one hackathon and isJudge authorizes against
+ // that, so a queue built for a judge from another edition can never be
+ // opened — the projects sit in it and are silently never scored.
+ // assignToHackathon makes exactly this check; this path did not.
+ const judge = await (ctx.db as DrizzleDB).query.judges.findFirst({
+ where: eq(judges.id, input.judgeId),
+ columns: { hackathonId: true },
+ });
+
+ if (!judge) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Judge not found" });
+ }
+
+ if (judge.hackathonId !== input.hackathonId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "This judge belongs to a different hackathon",
+ });
+ }
+
await (ctx.db as DrizzleDB)
.delete(judgeQueue)
.where(
@@ -564,6 +467,10 @@ export const judgeAdminRouter = createTRPCRouter({
* When true, they stay grouped in table order. */
groupSpecial: z.boolean().default(false),
autoCalculate: z.boolean().default(true),
+ /** Rebuild even though judging is live or work has been completed.
+ * Completed slots are still carried over; this only waives the
+ * refusal, so the admin has to have seen the count first. */
+ force: z.boolean().default(false),
}),
)
.mutation(async ({ ctx, input }) => {
@@ -577,6 +484,37 @@ export const judgeAdminRouter = createTRPCRouter({
message: "Hackathon not found",
});
+ // This procedure deletes and rebuilds every queue in the hackathon. Run
+ // a second time by accident — and the wizard drops you straight onto
+ // its button after a project import — it would restart judging for
+ // everyone at once, mid-event.
+ if (hackathon.judgingActive && !input.force) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message:
+ "Judging is live. Re-running assignment rebuilds every judge's queue. Stop judging first, or confirm to rebuild anyway.",
+ });
+ }
+
+ // Completed slots are not reconstructible from votes: skipProject marks
+ // a slot complete without writing one, so a wipe sends judges back to
+ // tables they already dealt with. judgingActive defaults false and
+ // organisers switch it off when judging closes, so the flag above
+ // cannot be the only guard.
+ const completed = await tx.query.judgeQueue.findMany({
+ where: and(
+ eq(judgeQueue.hackathonId, input.hackathonId),
+ eq(judgeQueue.isCompleted, true),
+ ),
+ });
+
+ if (completed.length > 0 && !input.force) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: `${completed.length} judging slot(s) are already complete. Rebuilding preserves them but reorders everything else — confirm to continue.`,
+ });
+ }
+
const allAssignments = await tx.query.judgeAssignments.findMany({
where: eq(judgeAssignments.hackathonId, input.hackathonId),
with: { judge: true },
@@ -702,29 +640,73 @@ export const judgeAdminRouter = createTRPCRouter({
},
);
- // Build all insert rows in one pass
+ // Build all insert rows in one pass, skipping pairs a judge has already
+ // finished. judge_queue has no unique on (judgeId, projectId), so
+ // without this filter the rebuild happily re-issues a completed pair as
+ // a fresh uncompleted row and getNextTable sends the judge back.
+ const completedKeys = new Set(
+ completed.map((row) => `${row.judgeId}:${row.projectId}`),
+ );
+
const insertRows: {
judgeId: string;
hackathonId: string;
projectId: string;
order: number;
+ isCompleted?: boolean;
+ startedAt?: Date | null;
+ completedAt?: Date | null;
}[] = [];
for (const [judgeId, projectIds] of queues.entries()) {
- projectIds.forEach((projectId, idx) => {
+ let order = 0;
+ for (const projectId of projectIds) {
+ if (completedKeys.has(`${judgeId}:${projectId}`)) continue;
insertRows.push({
judgeId,
hackathonId: input.hackathonId,
projectId,
- order: idx + 1,
+ order: ++order,
});
+ }
+ }
+
+ // Re-append the finished work past the tail of each judge's new queue,
+ // so their history survives and nothing re-serves it.
+ const tailByJudge = new Map();
+ for (const row of insertRows) {
+ tailByJudge.set(
+ row.judgeId,
+ Math.max(tailByJudge.get(row.judgeId) ?? 0, row.order),
+ );
+ }
+ for (const row of completed) {
+ const next = (tailByJudge.get(row.judgeId) ?? 0) + 1;
+ tailByJudge.set(row.judgeId, next);
+ insertRows.push({
+ judgeId: row.judgeId,
+ hackathonId: input.hackathonId,
+ projectId: row.projectId,
+ order: next,
+ isCompleted: true,
+ startedAt: row.startedAt,
+ completedAt: row.completedAt,
});
}
- if (insertRows.length > 0) {
- await tx.insert(judgeQueue).values(insertRows);
+ // Chunked because a single INSERT carries 4 bound parameters per row
+ // against Postgres's 65535 limit — about 16k rows. A sponsor-track
+ // judge's pool is uncapped, so a few of them over a large project list
+ // crosses it and aborts the whole assignment with an opaque driver
+ // error at the worst possible moment.
+ for (let i = 0; i < insertRows.length; i += QUEUE_INSERT_CHUNK) {
+ await tx
+ .insert(judgeQueue)
+ .values(insertRows.slice(i, i + QUEUE_INSERT_CHUNK));
}
- // Compute coverage stats for admin feedback
+ // Compute coverage stats for admin feedback. Counted over the merged
+ // set — over the generated rows alone, a fully-judged project reads as
+ // uncovered and the admin re-runs assignment chasing it.
const projectCoverage = new Map();
for (const row of insertRows) {
projectCoverage.set(
@@ -747,11 +729,18 @@ export const judgeAdminRouter = createTRPCRouter({
const maxCoverage =
coverageValues.length > 0 ? Math.max(...coverageValues) : 0;
+ // Counted from the rows actually written, not from `queues` — those
+ // still hold the completed pairs that were filtered out above.
+ const countByJudge = new Map();
+ for (const row of insertRows) {
+ countByJudge.set(row.judgeId, (countByJudge.get(row.judgeId) ?? 0) + 1);
+ }
+
const results = allAssignments.map((a) => ({
judgeId: a.judgeId,
judgeName: a.judge.name,
track: a.track ?? null,
- assignedCount: queues.get(a.judgeId)?.length ?? 0,
+ assignedCount: countByJudge.get(a.judgeId) ?? 0,
}));
return {
@@ -893,32 +882,6 @@ export const judgeAdminRouter = createTRPCRouter({
return result;
}),
- getAllVotes: isAdmin
- .input(z.object({ hackathonId: z.string().uuid() }))
- .query(async ({ ctx, input }) => {
- const projects = await (
- ctx.db as DrizzleDB
- ).query.judgingProjects.findMany({
- where: eq(judgingProjects.hackathonId, input.hackathonId),
- with: {
- votes: {
- with: {
- judge: {
- with: {
- user: {
- columns: { name: true },
- },
- },
- },
- },
- },
- },
- orderBy: [asc(judgingProjects.tableNumber)],
- });
-
- return projects;
- }),
-
register: protectedProcedure
.input(
z.object({
diff --git a/packages/api/src/routers/judge/portal.ts b/packages/api/src/routers/judge/portal.ts
index a3d90dfc..3efa32d4 100644
--- a/packages/api/src/routers/judge/portal.ts
+++ b/packages/api/src/routers/judge/portal.ts
@@ -7,7 +7,6 @@ import {
judgeVotes,
judgingProjects,
judgeQueue,
- hackathonMaps,
hackathons,
} from "@query/db";
import { eq, ne, gt, and, asc, inArray, sql } from "drizzle-orm";
@@ -234,17 +233,6 @@ export const judgePortalRouter = createTRPCRouter({
}));
}),
- getMaps: isJudge
- .input(z.object({ hackathonId: z.string().uuid() }))
- .query(async ({ ctx, input }) => {
- const maps = await (ctx.db as DrizzleDB).query.hackathonMaps.findMany({
- where: eq(hackathonMaps.hackathonId, input.hackathonId),
- orderBy: [asc(hackathonMaps.order)],
- });
-
- return maps;
- }),
-
getJudgingStatus: protectedProcedure
.input(z.object({ hackathonId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
@@ -636,7 +624,35 @@ export const judgePortalRouter = createTRPCRouter({
// Get the project's tracks for matching
const projectTracks = queueItem.project?.tracks || [];
- // Build candidate list with workload info
+ // Two queries for the whole candidate set, not two per candidate.
+ // This runs inside an open transaction during judging: at 40 judges
+ // the per-candidate version was ~80 sequential round trips, holding
+ // a pool connection the entire time.
+ const [holders, workloads] = await Promise.all([
+ tx
+ .select({ judgeId: judgeQueue.judgeId })
+ .from(judgeQueue)
+ .where(eq(judgeQueue.projectId, queueItem.projectId)),
+ tx
+ .select({
+ judgeId: judgeQueue.judgeId,
+ remaining: sql`count(*)::int`,
+ })
+ .from(judgeQueue)
+ .where(
+ and(
+ eq(judgeQueue.hackathonId, queueItem.hackathonId),
+ eq(judgeQueue.isCompleted, false),
+ ),
+ )
+ .groupBy(judgeQueue.judgeId),
+ ]);
+
+ const alreadyHolding = new Set(holders.map((row) => row.judgeId));
+ const remainingByJudge = new Map(
+ workloads.map((row) => [row.judgeId, row.remaining]),
+ );
+
const candidates: {
judgeId: string;
trackMatch: boolean;
@@ -650,26 +666,7 @@ export const judgePortalRouter = createTRPCRouter({
// them the project strands it with nobody able to score it.
if (!other.judge?.isActive) continue;
- // Check if already has this project
- const alreadyQueued = await tx.query.judgeQueue.findFirst({
- where: and(
- eq(judgeQueue.judgeId, other.judgeId),
- eq(judgeQueue.projectId, queueItem.projectId),
- ),
- });
- if (alreadyQueued) continue;
-
- // Count remaining (uncompleted) projects for workload balancing
- const remainingCount = await tx
- .select({ count: sql`COUNT(*)` })
- .from(judgeQueue)
- .where(
- and(
- eq(judgeQueue.judgeId, other.judgeId),
- eq(judgeQueue.hackathonId, queueItem.hackathonId),
- eq(judgeQueue.isCompleted, false),
- ),
- );
+ if (alreadyHolding.has(other.judgeId)) continue;
// Check track match: judge's assigned track overlaps with project's tracks
const trackMatch = other.track
@@ -679,7 +676,9 @@ export const judgePortalRouter = createTRPCRouter({
candidates.push({
judgeId: other.judgeId,
trackMatch,
- remaining: remainingCount[0]?.count ?? 0,
+ // A judge with nothing left has no group row at all, which is the
+ // lightest possible load rather than a missing one.
+ remaining: remainingByJudge.get(other.judgeId) ?? 0,
});
}
@@ -713,6 +712,17 @@ export const judgePortalRouter = createTRPCRouter({
orderBy: [asc(judgeQueue.order)],
});
+ // Claim the table being handed over, exactly as completeAndNext and
+ // skipProject do. Without this the slot stays unclaimed and the next
+ // judge to ask for work is sent to the table this judge just walked up
+ // to — two judges, one team, at the same moment.
+ if (nextInQueue) {
+ await tx
+ .update(judgeQueue)
+ .set({ startedAt: new Date() })
+ .where(eq(judgeQueue.id, nextInQueue.id));
+ }
+
return {
done: !nextInQueue,
project: nextInQueue?.project ?? null,
diff --git a/packages/api/src/routers/judge/rankings.ts b/packages/api/src/routers/judge/rankings.ts
index 784c27d7..a0c08f37 100644
--- a/packages/api/src/routers/judge/rankings.ts
+++ b/packages/api/src/routers/judge/rankings.ts
@@ -1,328 +1,529 @@
import { z } from "zod";
+import { TRPCError } from "@trpc/server";
import { createTRPCRouter } from "../../trpc";
-import {
- judgingProjects,
-} from "@query/db";
-import { eq } from "drizzle-orm";
+import { hackathonResults, hackathons, judgingProjects } from "@query/db";
+import { and, eq, isNotNull, sql , isNull } from "drizzle-orm";
import { isAdmin } from "../../middleware/procedures";
+import { recordAdminAction } from "../../middleware/audit";
import type { DrizzleDB } from "@query/db";
import { zNormalize } from "./helpers";
+/**
+ * The whole ranking pipeline, in one place.
+ *
+ * Extracted so the live view and the frozen snapshot cannot drift: two
+ * implementations of a scoring formula are two different answers to "who
+ * won", and only one of them gets announced.
+ */
+async function computeRanking(db: DrizzleDB, hackathonId: string) {
+ const projects = await db.query.judgingProjects.findMany({
+ // Withdrawn entries stop counting toward the ordering.
+ where: and(
+ eq(judgingProjects.hackathonId, hackathonId),
+ isNull(judgingProjects.withdrawnAt),
+ ),
+ with: {
+ votes: {
+ with: {
+ judge: {
+ with: {
+ user: {
+ columns: { name: true, email: true },
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ const round2 = (n: number) => Math.round(n * 100) / 100;
+
+ // ─── Step 1: Collect all raw scores grouped by judge ──────────────────
+ // We need per-judge score distributions to perform Z-score normalization,
+ // which eliminates the "harsh judge / lenient judge" bias problem.
+ type VoteWithJudge = (typeof projects)[number]["votes"][number];
+ const scoresByJudge = new Map();
+ for (const project of projects) {
+ for (const v of project.votes) {
+ const existing = scoresByJudge.get(v.judgeId) ?? [];
+ existing.push(v.score);
+ scoresByJudge.set(v.judgeId, existing);
+ }
+ }
+
+ // ─── Step 2: Compute global score distribution ─────────────────────────
+ const allRawScores = [...scoresByJudge.values()].flat();
+ const globalMean =
+ allRawScores.length > 0
+ ? allRawScores.reduce((a, b) => a + b, 0) / allRawScores.length
+ : 0;
+ const globalVariance =
+ allRawScores.length > 0
+ ? allRawScores.reduce((s, v) => s + (v - globalMean) ** 2, 0) /
+ allRawScores.length
+ : 1;
+ const globalStd = Math.sqrt(globalVariance) || 1;
+
+ // ─── Step 3: Build per-judge normalized score lookup ──────────────────
+ // For each judge, map their raw score index to a Z-normalized score.
+ const normalizedScoreLookup = new Map>();
+ for (const [judgeId, rawScores] of scoresByJudge.entries()) {
+ const normalized = zNormalize(rawScores, globalMean, globalStd);
+ // Map raw score value -> normalized value (index-based, preserves order)
+ const lookup = new Map();
+ rawScores.forEach((raw, i) => {
+ // If same raw score appears multiple times, average the normalized values
+ const existing = lookup.get(raw);
+ lookup.set(
+ raw,
+ existing !== undefined
+ ? (existing + normalized[i]!) / 2
+ : normalized[i]!,
+ );
+ });
+ normalizedScoreLookup.set(judgeId, lookup);
+ }
+
+ const getNormalized = (judgeId: string, rawScore: number): number => {
+ const lookup = normalizedScoreLookup.get(judgeId);
+ return lookup?.get(rawScore) ?? rawScore;
+ };
+
+ // ─── Step 4: Build raw + normalized stats per project ─────────────────
+ const C = 2; // Bayesian confidence weight
+
+ const rawRankings = projects.map((project) => {
+ const voteCount = project.votes.length;
+
+ // Raw scores (unadjusted)
+ const totalScore = project.votes.reduce((sum, v) => sum + v.score, 0);
+ const avgScore = voteCount > 0 ? totalScore / voteCount : 0;
+
+ // Z-score normalized scores (bias-corrected)
+ const normalizedScores = project.votes.map((v) =>
+ getNormalized(v.judgeId, v.score),
+ );
+ const normalizedAvg =
+ voteCount > 0
+ ? round2(normalizedScores.reduce((a, b) => a + b, 0) / voteCount)
+ : 0;
+
+ // Per-category averages (raw)
+ const sumCat = {
+ creativity: 0,
+ impact: 0,
+ scope: 0,
+ clarity: 0,
+ soundness: 0,
+ };
+ project.votes.forEach((v) => {
+ sumCat.creativity += v.scoreCreativity ?? 0;
+ sumCat.impact += v.scoreImpact ?? 0;
+ sumCat.scope += v.scoreScope ?? 0;
+ sumCat.clarity += v.scoreClarity ?? 0;
+ sumCat.soundness += v.scoreSoundness ?? 0;
+ });
+
+ const categoryAvg =
+ voteCount > 0
+ ? {
+ creativity: round2(sumCat.creativity / voteCount),
+ impact: round2(sumCat.impact / voteCount),
+ scope: round2(sumCat.scope / voteCount),
+ clarity: round2(sumCat.clarity / voteCount),
+ soundness: round2(sumCat.soundness / voteCount),
+ }
+ : { creativity: 0, impact: 0, scope: 0, clarity: 0, soundness: 0 };
+
+ return {
+ project: {
+ id: project.id,
+ // Carried through so a frozen placing can name the team that built it.
+ // Without it a winner is a judging row and nothing more.
+ sourceProjectId: project.sourceProjectId,
+ name: project.name,
+ tableNumber: project.tableNumber,
+ zone: project.zone,
+ category: project.category,
+ teamMembers: project.teamMembers,
+ tracks: project.tracks,
+ challenges: project.challenges,
+ isCreateX: project.isCreateX,
+ },
+ totalScore,
+ voteCount,
+ avgScore: round2(avgScore),
+ normalizedAvg,
+ categoryAvg,
+ votes: project.votes.map((v, i) => ({
+ score: v.score,
+ normalizedScore: round2(normalizedScores[i] ?? v.score),
+ scoreCreativity: v.scoreCreativity,
+ scoreImpact: v.scoreImpact,
+ scoreScope: v.scoreScope,
+ scoreClarity: v.scoreClarity,
+ scoreSoundness: v.scoreSoundness,
+ comment: v.comment,
+ durationSeconds: v.durationSeconds,
+ judgeName:
+ (
+ v as VoteWithJudge & {
+ judge: {
+ user?: { name?: string | null };
+ name?: string | null;
+ };
+ }
+ ).judge.user?.name ||
+ (
+ v as VoteWithJudge & {
+ judge: {
+ user?: { name?: string | null };
+ name?: string | null;
+ };
+ }
+ ).judge.name ||
+ "Unknown",
+ })),
+ };
+ });
+
+ // ─── Step 5: Compute global normalized average for Bayesian prior ──────
+ const votedProjects = rawRankings.filter((r) => r.voteCount > 0);
+ const globalAvg =
+ votedProjects.length > 0
+ ? round2(
+ votedProjects.reduce((sum, r) => sum + r.normalizedAvg, 0) /
+ votedProjects.length,
+ )
+ : 0;
+
+ // ─── Step 6: Bayesian + Z-score combined final score ──────────────────
+ // weightedScore blends normalized avg toward the global mean when few judges voted.
+ const rankings = rawRankings.map((r) => {
+ const n = r.voteCount;
+ const weightedScore =
+ n > 0
+ ? round2(
+ (n / (n + C)) * r.normalizedAvg + (C / (n + C)) * globalAvg,
+ )
+ : 0;
+ const confidenceLevel: "NONE" | "LOW" | "MEDIUM" | "HIGH" =
+ n === 0 ? "NONE" : n === 1 ? "LOW" : n === 2 ? "MEDIUM" : "HIGH";
+ const scoreShift = round2(r.normalizedAvg - r.avgScore); // how much bias-correction shifted this project
+
+ return { ...r, weightedScore, confidenceLevel, scoreShift };
+ });
+
+ // Sort by weighted score desc
+ rankings.sort((a, b) => b.weightedScore - a.weightedScore);
+
+ // Weighted-score ties
+ const ties: {
+ score: number;
+ projects: {
+ id: string;
+ name: string;
+ tableNumber: number;
+ zone: string | null;
+ }[];
+ }[] = [];
+ const scoreGroups = new Map();
+
+ rankings.forEach((r) => {
+ const existing = scoreGroups.get(r.weightedScore);
+ if (existing) {
+ existing.push(r);
+ } else {
+ scoreGroups.set(r.weightedScore, [r]);
+ }
+ });
+
+ scoreGroups.forEach((group, score) => {
+ if (group.length > 1) {
+ ties.push({
+ score,
+ projects: group.map((g) => ({
+ id: g.project.id,
+ name: g.project.name,
+ tableNumber: g.project.tableNumber,
+ zone: g.project.zone ?? null,
+ })),
+ });
+ }
+ });
+
+ // Per-category ties (only among projects with votes)
+ const categoryNames = [
+ "creativity",
+ "impact",
+ "scope",
+ "clarity",
+ "soundness",
+ ] as const;
+ const categoryLabels: Record<(typeof categoryNames)[number], string> = {
+ creativity: "Creativity",
+ impact: "Impact",
+ scope: "Scope",
+ clarity: "Clarity",
+ soundness: "Soundness",
+ };
+
+ const categoryTies: {
+ category: string;
+ avgScore: number;
+ projects: {
+ id: string;
+ name: string;
+ tableNumber: number;
+ zone: string | null;
+ }[];
+ }[] = [];
+
+ for (const cat of categoryNames) {
+ const catGroups = new Map<
+ number,
+ {
+ id: string;
+ name: string;
+ tableNumber: number;
+ zone: string | null;
+ }[]
+ >();
+ rankings.forEach((r) => {
+ if (r.voteCount === 0) return;
+ const avg = r.categoryAvg[cat];
+ const existing = catGroups.get(avg);
+ const projectInfo = {
+ id: r.project.id,
+ name: r.project.name,
+ tableNumber: r.project.tableNumber,
+ zone: r.project.zone ?? null,
+ };
+ if (existing) {
+ existing.push(projectInfo);
+ } else {
+ catGroups.set(avg, [projectInfo]);
+ }
+ });
+ catGroups.forEach((group, avg) => {
+ if (group.length > 1) {
+ categoryTies.push({
+ category: categoryLabels[cat],
+ avgScore: avg,
+ projects: group,
+ });
+ }
+ });
+ }
+
+ const result = {
+ rankings,
+ globalAvg,
+ ties,
+ hasTies: ties.length > 0,
+ categoryTies,
+ hasCategoryTies: categoryTies.length > 0,
+ };
+
+ return result;
+}
+
export const judgeRankingsRouter = createTRPCRouter({
getRankings: isAdmin
.input(z.object({ hackathonId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const cacheKey = `hackathon:${input.hackathonId}:rankings`;
- const cached = ctx.cache.get(cacheKey);
+ const cached =
+ ctx.cache.get>>(cacheKey);
if (cached) return cached;
- const projects = await (
- ctx.db as DrizzleDB
- ).query.judgingProjects.findMany({
- where: eq(judgingProjects.hackathonId, input.hackathonId),
- with: {
- votes: {
- with: {
- judge: {
- with: {
- user: {
- columns: { name: true, email: true },
- },
- },
- },
- },
- },
- },
+ const result = await computeRanking(
+ ctx.db as DrizzleDB,
+ input.hackathonId,
+ );
+
+ ctx.cache.set(cacheKey, result, 30); // 30 second cache for live rankings
+
+ return result;
+ }),
+
+ /**
+ * Freezes the current ordering into hackathon_result.
+ *
+ * Gated on judging being closed: the z-score normalisation runs over the
+ * whole vote set, so a single vote arriving after this would have shifted
+ * every score. Computing while judging is live produces a snapshot that is
+ * already stale.
+ *
+ * Idempotent — recomputing upserts onto result_unique_placing rather than
+ * appending a second, contradictory ordering. Published placings are left
+ * alone; unpublish first if you mean to change what people have seen.
+ */
+ computeResults: isAdmin
+ .input(
+ z.object({
+ hackathonId: z.string().uuid(),
+ /** Compute even though judging is still open. The result is a draft
+ * of an ordering that is still moving. */
+ force: z.boolean().default(false),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const hackathon = await db.query.hackathons.findFirst({
+ where: eq(hackathons.id, input.hackathonId),
+ columns: { id: true, judgingActive: true },
});
- const round2 = (n: number) => Math.round(n * 100) / 100;
-
- // ─── Step 1: Collect all raw scores grouped by judge ──────────────────
- // We need per-judge score distributions to perform Z-score normalization,
- // which eliminates the "harsh judge / lenient judge" bias problem.
- type VoteWithJudge = (typeof projects)[number]["votes"][number];
- const scoresByJudge = new Map();
- for (const project of projects) {
- for (const v of project.votes) {
- const existing = scoresByJudge.get(v.judgeId) ?? [];
- existing.push(v.score);
- scoresByJudge.set(v.judgeId, existing);
- }
+ if (!hackathon) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Hackathon not found",
+ });
}
- // ─── Step 2: Compute global score distribution ─────────────────────────
- const allRawScores = [...scoresByJudge.values()].flat();
- const globalMean =
- allRawScores.length > 0
- ? allRawScores.reduce((a, b) => a + b, 0) / allRawScores.length
- : 0;
- const globalVariance =
- allRawScores.length > 0
- ? allRawScores.reduce((s, v) => s + (v - globalMean) ** 2, 0) /
- allRawScores.length
- : 1;
- const globalStd = Math.sqrt(globalVariance) || 1;
-
- // ─── Step 3: Build per-judge normalized score lookup ──────────────────
- // For each judge, map their raw score index to a Z-normalized score.
- const normalizedScoreLookup = new Map>();
- for (const [judgeId, rawScores] of scoresByJudge.entries()) {
- const normalized = zNormalize(rawScores, globalMean, globalStd);
- // Map raw score value -> normalized value (index-based, preserves order)
- const lookup = new Map();
- rawScores.forEach((raw, i) => {
- // If same raw score appears multiple times, average the normalized values
- const existing = lookup.get(raw);
- lookup.set(
- raw,
- existing !== undefined
- ? (existing + normalized[i]!) / 2
- : normalized[i]!,
- );
+ if (hackathon.judgingActive && !input.force) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message:
+ "Judging is still live, so scores are still moving. Stop judging first, or confirm to compute a draft anyway.",
});
- normalizedScoreLookup.set(judgeId, lookup);
}
- const getNormalized = (judgeId: string, rawScore: number): number => {
- const lookup = normalizedScoreLookup.get(judgeId);
- return lookup?.get(rawScore) ?? rawScore;
- };
+ const published = await db.query.hackathonResults.findFirst({
+ where: and(
+ eq(hackathonResults.hackathonId, input.hackathonId),
+ isNotNull(hackathonResults.publishedAt),
+ ),
+ columns: { id: true },
+ });
- // ─── Step 4: Build raw + normalized stats per project ─────────────────
- const C = 2; // Bayesian confidence weight
-
- const rawRankings = projects.map((project) => {
- const voteCount = project.votes.length;
-
- // Raw scores (unadjusted)
- const totalScore = project.votes.reduce((sum, v) => sum + v.score, 0);
- const avgScore = voteCount > 0 ? totalScore / voteCount : 0;
-
- // Z-score normalized scores (bias-corrected)
- const normalizedScores = project.votes.map((v) =>
- getNormalized(v.judgeId, v.score),
- );
- const normalizedAvg =
- voteCount > 0
- ? round2(normalizedScores.reduce((a, b) => a + b, 0) / voteCount)
- : 0;
-
- // Per-category averages (raw)
- const sumCat = {
- creativity: 0,
- impact: 0,
- scope: 0,
- clarity: 0,
- soundness: 0,
- };
- project.votes.forEach((v) => {
- sumCat.creativity += v.scoreCreativity ?? 0;
- sumCat.impact += v.scoreImpact ?? 0;
- sumCat.scope += v.scoreScope ?? 0;
- sumCat.clarity += v.scoreClarity ?? 0;
- sumCat.soundness += v.scoreSoundness ?? 0;
+ if (published) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message:
+ "Results are already published. Unpublish them before recomputing.",
});
+ }
- const categoryAvg =
- voteCount > 0
- ? {
- creativity: round2(sumCat.creativity / voteCount),
- impact: round2(sumCat.impact / voteCount),
- scope: round2(sumCat.scope / voteCount),
- clarity: round2(sumCat.clarity / voteCount),
- soundness: round2(sumCat.soundness / voteCount),
- }
- : { creativity: 0, impact: 0, scope: 0, clarity: 0, soundness: 0 };
-
- return {
- project: {
- id: project.id,
- name: project.name,
- tableNumber: project.tableNumber,
- zone: project.zone,
- category: project.category,
- teamMembers: project.teamMembers,
- tracks: project.tracks,
- challenges: project.challenges,
- isCreateX: project.isCreateX,
- },
- totalScore,
- voteCount,
- avgScore: round2(avgScore),
- normalizedAvg,
- categoryAvg,
- votes: project.votes.map((v, i) => ({
- score: v.score,
- normalizedScore: round2(normalizedScores[i] ?? v.score),
- scoreCreativity: v.scoreCreativity,
- scoreImpact: v.scoreImpact,
- scoreScope: v.scoreScope,
- scoreClarity: v.scoreClarity,
- scoreSoundness: v.scoreSoundness,
- comment: v.comment,
- durationSeconds: v.durationSeconds,
- judgeName:
- (
- v as VoteWithJudge & {
- judge: {
- user?: { name?: string | null };
- name?: string | null;
- };
- }
- ).judge.user?.name ||
- (
- v as VoteWithJudge & {
- judge: {
- user?: { name?: string | null };
- name?: string | null;
- };
- }
- ).judge.name ||
- "Unknown",
+ // Reuses the live ranking pipeline rather than duplicating the maths —
+ // two implementations of a scoring formula is two answers to "who won".
+ const { rankings } = await computeRanking(db, input.hackathonId);
+
+ // A project nobody scored is not a placing. computeRanking gives every
+ // unjudged entry a weightedScore of 0, so including them would publish
+ // hundreds of rows tied at zero in arbitrary order below the real
+ // results — and "47th place" is a worse thing to tell a team than
+ // nothing at all.
+ const placed = rankings.filter((row) => row.voteCount > 0);
+
+ if (placed.length === 0) {
+ return { computed: 0, unjudged: rankings.length };
+ }
+
+ await db
+ .insert(hackathonResults)
+ .values(
+ placed.map((row, index) => ({
+ hackathonId: input.hackathonId,
+ projectId: row.project.id,
+ sourceProjectId: row.project.sourceProjectId ?? null,
+ // Never null — see the column comment. A NULL here silently
+ // defeats result_unique_placing and duplicates the ordering.
+ track: "overall",
+ placement: index + 1,
+ weightedScore: row.weightedScore.toFixed(2),
+ voteCount: row.voteCount,
})),
- };
- });
+ )
+ .onConflictDoUpdate({
+ target: [
+ hackathonResults.hackathonId,
+ hackathonResults.projectId,
+ hackathonResults.track,
+ ],
+ set: {
+ placement: sql`excluded.placement`,
+ weightedScore: sql`excluded.weighted_score`,
+ voteCount: sql`excluded.vote_count`,
+ computedAt: sql`now()`,
+ },
+ });
- // ─── Step 5: Compute global normalized average for Bayesian prior ──────
- const votedProjects = rawRankings.filter((r) => r.voteCount > 0);
- const globalAvg =
- votedProjects.length > 0
- ? round2(
- votedProjects.reduce((sum, r) => sum + r.normalizedAvg, 0) /
- votedProjects.length,
- )
- : 0;
-
- // ─── Step 6: Bayesian + Z-score combined final score ──────────────────
- // weightedScore blends normalized avg toward the global mean when few judges voted.
- const rankings = rawRankings.map((r) => {
- const n = r.voteCount;
- const weightedScore =
- n > 0
- ? round2(
- (n / (n + C)) * r.normalizedAvg + (C / (n + C)) * globalAvg,
- )
- : 0;
- const confidenceLevel: "NONE" | "LOW" | "MEDIUM" | "HIGH" =
- n === 0 ? "NONE" : n === 1 ? "LOW" : n === 2 ? "MEDIUM" : "HIGH";
- const scoreShift = round2(r.normalizedAvg - r.avgScore); // how much bias-correction shifted this project
-
- return { ...r, weightedScore, confidenceLevel, scoreShift };
- });
+ ctx.cache.delete(`hackathon:${input.hackathonId}:results`);
- // Sort by weighted score desc
- rankings.sort((a, b) => b.weightedScore - a.weightedScore);
-
- // Weighted-score ties
- const ties: {
- score: number;
- projects: {
- id: string;
- name: string;
- tableNumber: number;
- zone: string | null;
- }[];
- }[] = [];
- const scoreGroups = new Map();
-
- rankings.forEach((r) => {
- const existing = scoreGroups.get(r.weightedScore);
- if (existing) {
- existing.push(r);
- } else {
- scoreGroups.set(r.weightedScore, [r]);
- }
- });
+ // Reported separately so an organiser can see that, say, 40 of 300
+ // projects were never reached before they publish.
+ return {
+ computed: placed.length,
+ unjudged: rankings.length - placed.length,
+ };
+ }),
- scoreGroups.forEach((group, score) => {
- if (group.length > 1) {
- ties.push({
- score,
- projects: group.map((g) => ({
- id: g.project.id,
- name: g.project.name,
- tableNumber: g.project.tableNumber,
- zone: g.project.zone ?? null,
- })),
- });
- }
+ /** What has been computed, published or not. Admin review before release. */
+ getResultsDraft: isAdmin
+ .input(z.object({ hackathonId: z.string().uuid() }))
+ .query(async ({ ctx, input }) => {
+ return await (ctx.db as DrizzleDB).query.hackathonResults.findMany({
+ where: eq(hackathonResults.hackathonId, input.hackathonId),
+ with: { project: { columns: { id: true, name: true, tableNumber: true } } },
+ orderBy: (results, { asc }) => [asc(results.placement)],
});
+ }),
- // Per-category ties (only among projects with votes)
- const categoryNames = [
- "creativity",
- "impact",
- "scope",
- "clarity",
- "soundness",
- ] as const;
- const categoryLabels: Record<(typeof categoryNames)[number], string> = {
- creativity: "Creativity",
- impact: "Impact",
- scope: "Scope",
- clarity: "Clarity",
- soundness: "Soundness",
- };
+ publishResults: isAdmin
+ .input(z.object({ hackathonId: z.string().uuid() }))
+ .mutation(async ({ ctx, input }) => {
+ const rows = await (ctx.db as DrizzleDB)
+ .update(hackathonResults)
+ .set({ publishedAt: new Date() })
+ .where(eq(hackathonResults.hackathonId, input.hackathonId))
+ .returning({ id: hackathonResults.id });
- const categoryTies: {
- category: string;
- avgScore: number;
- projects: {
- id: string;
- name: string;
- tableNumber: number;
- zone: string | null;
- }[];
- }[] = [];
-
- for (const cat of categoryNames) {
- const catGroups = new Map<
- number,
- {
- id: string;
- name: string;
- tableNumber: number;
- zone: string | null;
- }[]
- >();
- rankings.forEach((r) => {
- if (r.voteCount === 0) return;
- const avg = r.categoryAvg[cat];
- const existing = catGroups.get(avg);
- const projectInfo = {
- id: r.project.id,
- name: r.project.name,
- tableNumber: r.project.tableNumber,
- zone: r.project.zone ?? null,
- };
- if (existing) {
- existing.push(projectInfo);
- } else {
- catGroups.set(avg, [projectInfo]);
- }
- });
- catGroups.forEach((group, avg) => {
- if (group.length > 1) {
- categoryTies.push({
- category: categoryLabels[cat],
- avgScore: avg,
- projects: group,
- });
- }
+ if (rows.length === 0) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Nothing to publish — compute the results first.",
});
}
- const result = {
- rankings,
- globalAvg,
- ties,
- hasTies: ties.length > 0,
- categoryTies,
- hasCategoryTies: categoryTies.length > 0,
- };
+ await recordAdminAction(ctx.db as DrizzleDB, {
+ userId: ctx.userId,
+ action: "judge.publishResults",
+ resourceId: input.hackathonId,
+ severity: "warn",
+ metadata: { placings: rows.length },
+ });
- ctx.cache.set(cacheKey, result, 30); // 30 second cache for live rankings
+ ctx.cache.delete(`hackathon:${input.hackathonId}:results`);
- return result;
+ return { published: rows.length };
+ }),
+
+ /** Takes results back down. The rows survive, so publishing is reversible
+ * rather than a one-way door on a wrong ordering. */
+ unpublishResults: isAdmin
+ .input(z.object({ hackathonId: z.string().uuid() }))
+ .mutation(async ({ ctx, input }) => {
+ const rows = await (ctx.db as DrizzleDB)
+ .update(hackathonResults)
+ .set({ publishedAt: null })
+ .where(eq(hackathonResults.hackathonId, input.hackathonId))
+ .returning({ id: hackathonResults.id });
+
+ // Taking results back down after people have seen them.
+ await recordAdminAction(ctx.db as DrizzleDB, {
+ userId: ctx.userId,
+ action: "judge.unpublishResults",
+ resourceId: input.hackathonId,
+ severity: "critical",
+ metadata: { placings: rows.length },
+ });
+
+ ctx.cache.delete(`hackathon:${input.hackathonId}:results`);
+
+ return { unpublished: rows.length };
}),
});
diff --git a/packages/api/src/routers/member.ts b/packages/api/src/routers/member.ts
index 07d18bc7..ee0a6149 100644
--- a/packages/api/src/routers/member.ts
+++ b/packages/api/src/routers/member.ts
@@ -6,8 +6,10 @@ import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { members } from "@query/db";
import { eq, and } from "drizzle-orm";
import type { DrizzleDB } from "@query/db";
-import { invalidatePortalContext } from "../middleware/cache";
-import { resolveHackathonId } from "../services/portal-context";
+import {
+ clearMembershipCaches,
+ invalidatePortalContext,
+} from "../middleware/cache";
// Letters from every script, plus the combining marks, spaces, hyphens and
// apostrophes (straight and typographic) that real names are written with.
@@ -24,20 +26,13 @@ const phoneSchema = z
export const memberRouter = createTRPCRouter({
me: protectedProcedure
- .input(z.object({ hackathonId: z.string().uuid().optional() }).optional())
- .query(async ({ ctx, input }) => {
- const hackathonId = await resolveHackathonId(ctx.db as DrizzleDB, input?.hackathonId);
- if (!hackathonId) return null;
-
- const cacheKey = `member:me:${ctx.userId}:${hackathonId}`;
+ .query(async ({ ctx }) => {
+ const cacheKey = `member:me:${ctx.userId}`;
const cached = ctx.cache.get(cacheKey);
if (cached) return cached;
const member = await (ctx.db as DrizzleDB).query.members.findFirst({
- where: and(
- eq(members.userId, ctx.userId!),
- eq(members.hackathonId, hackathonId),
- ),
+ where: eq(members.userId, ctx.userId!),
});
const result = member ?? null;
@@ -48,7 +43,6 @@ export const memberRouter = createTRPCRouter({
register: protectedProcedure
.input(
z.object({
- hackathonId: z.string().uuid().optional(),
firstName: nameSchema,
lastName: nameSchema,
phoneNumber: phoneSchema,
@@ -63,27 +57,16 @@ export const memberRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
- const hackathonId = await resolveHackathonId(ctx.db as DrizzleDB, input.hackathonId);
- if (!hackathonId) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "No hackathon context found for registration",
- });
- }
-
const existingMember = await (
ctx.db as DrizzleDB
).query.members.findFirst({
- where: and(
- eq(members.userId, ctx.userId!),
- eq(members.hackathonId, hackathonId),
- ),
+ where: eq(members.userId, ctx.userId!),
});
if (existingMember) {
throw new TRPCError({
code: "BAD_REQUEST",
- message: "You are already a member for this hackathon",
+ message: "You already have a member profile",
});
}
@@ -106,7 +89,6 @@ export const memberRouter = createTRPCRouter({
.insert(members)
.values({
userId: ctx.userId!,
- hackathonId,
memberType: "new",
firstName: input.firstName,
lastName: input.lastName,
@@ -153,7 +135,6 @@ export const memberRouter = createTRPCRouter({
update: protectedProcedure
.input(
z.object({
- hackathonId: z.string().uuid().optional(),
firstName: nameSchema.optional(),
lastName: nameSchema.optional(),
phoneNumber: phoneSchema,
@@ -168,35 +149,21 @@ export const memberRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
- const hackathonId = await resolveHackathonId(ctx.db as DrizzleDB, input.hackathonId);
- if (!hackathonId) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "No hackathon context found for update",
- });
- }
-
const member = await (ctx.db as DrizzleDB).query.members.findFirst({
- where: and(
- eq(members.userId, ctx.userId!),
- eq(members.hackathonId, hackathonId),
- ),
+ where: eq(members.userId, ctx.userId!),
});
if (!member) {
throw new TRPCError({
code: "NOT_FOUND",
- message: "Member not found for this hackathon",
+ message: "Member not found",
});
}
- // Exclude hackathonId from update fields
- const { hackathonId: _, ...updateFields } = input;
-
const result = await (ctx.db as DrizzleDB)
.update(members)
.set({
- ...updateFields,
+ ...input,
updatedAt: new Date(),
})
.where(eq(members.id, member.id))
@@ -211,28 +178,29 @@ export const memberRouter = createTRPCRouter({
});
}
+ // `me` caches for 60s; without this the form saves and re-reads the old
+ // values, which is indistinguishable from the save having failed.
+ clearMembershipCaches(ctx.userId!);
+
return updatedMember;
}),
list: publicProcedure
.input(
z.object({
- hackathonId: z.string().uuid().optional(),
memberType: z.enum(["new", "continuous"]).optional(),
limit: z.number().int().min(1).max(100).default(50),
offset: z.number().int().min(0).max(10000).default(0),
}),
)
.query(async ({ ctx, input }) => {
- const hackathonId = await resolveHackathonId(ctx.db as DrizzleDB, input.hackathonId);
- const cacheKey = `members:list:${hackathonId || "all"}:${input.memberType || "all"}:${input.limit}:${input.offset}`;
+ const cacheKey = `members:list:${input.memberType || "all"}:${input.limit}:${input.offset}`;
const cached = ctx.cache.get(cacheKey);
if (cached) return cached;
const allMembers = await (ctx.db as DrizzleDB).query.members.findMany({
where: and(
eq(members.isActive, true),
- hackathonId ? eq(members.hackathonId, hackathonId) : undefined,
input.memberType
? eq(members.memberType, input.memberType)
: undefined,
@@ -306,21 +274,9 @@ export const memberRouter = createTRPCRouter({
}),
history: protectedProcedure
- .input(z.object({ hackathonId: z.string().uuid().optional() }).optional())
- .query(async ({ ctx, input }) => {
- const hackathonId = await resolveHackathonId(ctx.db as DrizzleDB, input?.hackathonId);
- if (!hackathonId) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "No hackathon context found for history lookup",
- });
- }
-
+ .query(async ({ ctx }) => {
const member = await (ctx.db as DrizzleDB).query.members.findFirst({
- where: and(
- eq(members.userId, ctx.userId!),
- eq(members.hackathonId, hackathonId),
- ),
+ where: eq(members.userId, ctx.userId!),
columns: { id: true },
with: {
membershipHistory: {
@@ -331,29 +287,15 @@ export const memberRouter = createTRPCRouter({
});
if (!member) {
- throw new TRPCError({ code: "NOT_FOUND", message: "Member not found for this hackathon" });
+ throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
}
return member.membershipHistory;
}),
checkStatus: protectedProcedure
- .input(z.object({ hackathonId: z.string().uuid().optional() }).optional())
- .query(async ({ ctx, input }) => {
- const hackathonId = await resolveHackathonId(ctx.db as DrizzleDB, input?.hackathonId);
- if (!hackathonId) {
- return {
- isMember: false,
- isActive: false,
- hasLapsed: false,
- expiresAt: null,
- daysRemaining: null,
- memberType: null,
- renewalCount: 0,
- };
- }
-
- const cacheKey = `member:status:${ctx.userId}:${hackathonId}`;
+ .query(async ({ ctx }) => {
+ const cacheKey = `member:status:${ctx.userId}`;
const cached = ctx.cache.get<{
isMember: boolean;
isActive: boolean;
@@ -366,10 +308,7 @@ export const memberRouter = createTRPCRouter({
if (cached) return cached;
const member = await (ctx.db as DrizzleDB).query.members.findFirst({
- where: and(
- eq(members.userId, ctx.userId!),
- eq(members.hackathonId, hackathonId),
- ),
+ where: eq(members.userId, ctx.userId!),
});
if (!member) {
@@ -415,4 +354,5 @@ export const memberRouter = createTRPCRouter({
return result;
}),
+
});
diff --git a/packages/api/src/routers/stripe.ts b/packages/api/src/routers/stripe.ts
index f0e45478..35801e2a 100644
--- a/packages/api/src/routers/stripe.ts
+++ b/packages/api/src/routers/stripe.ts
@@ -1,9 +1,15 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure } from "../trpc";
-import { stripePayments, userAccountLinks, users } from "@query/db";
+import {
+ members,
+ membershipHistory,
+ stripePayments,
+ userAccountLinks,
+ users,
+} from "@query/db";
import type { DrizzleDB } from "@query/db";
-import { eq, and, isNull } from "drizzle-orm";
+import { eq, and, gte, isNull } from "drizzle-orm";
import { logSecurityEvent } from "../middleware/security";
import { clearMembershipCaches as clearMembershipCachesFor } from "../middleware/cache";
import {
@@ -272,8 +278,14 @@ export const stripeRouter = createTRPCRouter({
// Checked before the key, matching createCheckoutSession, so local
// development needs no Stripe key at all.
if (isMockMode()) {
+ // A real, unique id so the mock flow goes through the SAME
+ // confirmMembershipAfterPayment path production uses — including its
+ // idempotency check on stripePaymentIntentId. A fixed placeholder
+ // would collide across runs and make the second developer's payment a
+ // silent no-op.
return {
clientSecret: "mock_pi_secret",
+ mockPaymentIntentId: `pi_mock_${crypto.randomUUID().replace(/-/g, "")}`,
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "pk_test_mock",
isMock: true,
};
@@ -354,18 +366,52 @@ export const stripeRouter = createTRPCRouter({
confirmMembershipAfterPayment: protectedProcedure
.input(z.object({ paymentIntentId: z.string() }))
.mutation(async ({ ctx, input }) => {
- // No key-mode check here: this path hands no publishable key to the
- // client, so the two cannot disagree.
- const stripe = await getStripe();
- if (!stripe) {
- throw new TRPCError({
- code: "SERVICE_UNAVAILABLE",
- message: "Payment service unavailable.",
- });
+ // Mock mode grants the membership through this same procedure rather
+ // than a parallel branch, so local development exercises the production
+ // path: same idempotency check, same membership service, same cache
+ // eviction. Previously the modal called onSuccess() directly and the UI
+ // reported "Access Granted" with nothing written anywhere.
+ //
+ // isMockMode() is false whenever NODE_ENV=production regardless of the
+ // flag, so this cannot mint free memberships on the live site.
+ const mock = isMockMode() && input.paymentIntentId.startsWith("pi_mock_");
+
+ // Only the fields this procedure reads. Structural rather than Stripe's
+ // own type so the mock object can satisfy it without inventing the
+ // hundred properties a real PaymentIntent carries.
+ let pi: {
+ id: string;
+ status: string;
+ amount: number;
+ currency: string;
+ customer?: string | { id: string } | null;
+ receipt_email?: string | null;
+ metadata?: Record;
+ };
+
+ if (mock) {
+ pi = {
+ id: input.paymentIntentId,
+ status: "succeeded",
+ amount: priceForCents(false),
+ currency: "usd",
+ metadata: { userId: ctx.userId!, bootcamp: "false" },
+ };
+ } else {
+ // No key-mode check here: this path hands no publishable key to the
+ // client, so the two cannot disagree.
+ const stripe = await getStripe();
+ if (!stripe) {
+ throw new TRPCError({
+ code: "SERVICE_UNAVAILABLE",
+ message: "Payment service unavailable.",
+ });
+ }
+
+ // Verify with Stripe that payment actually succeeded
+ pi = await stripe.paymentIntents.retrieve(input.paymentIntentId);
}
- // Verify with Stripe that payment actually succeeded
- const pi = await stripe.paymentIntents.retrieve(input.paymentIntentId);
if (pi.status !== "succeeded") {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -571,7 +617,56 @@ export const stripeRouter = createTRPCRouter({
* strand it. Claim it and grant the membership instead.
*/
if (existing) {
- if (existing.linkedUserId) continue;
+ // Somebody else's payment. Not ours to touch.
+ if (existing.linkedUserId && existing.linkedUserId !== ctx.userId) {
+ continue;
+ }
+
+ /**
+ * Linked to this user, which is NOT proof the membership was granted.
+ *
+ * The webhook records the payment first and grants afterwards, on
+ * purpose — sharing a transaction meant a failed grant rolled the
+ * payment row back and lost the charge entirely. But that ordering
+ * leaves a real state where the row is linked and no membership
+ * exists, and skipping every linked payment here made that state
+ * permanent: the customer is charged, the payment is on file, and
+ * nothing ever retries.
+ *
+ * `membership_history` is what tells the two apart. Every grant writes
+ * a row, so a payment with no history row at or after its own
+ * timestamp was never honoured. That distinguishes a failed grant from
+ * a membership that was granted a year ago and has since lapsed —
+ * which must NOT be silently renewed off an old payment.
+ */
+ if (existing.linkedUserId) {
+ const member = await ctx.db!.query.members.findFirst({
+ where: eq(members.userId, ctx.userId!),
+ columns: { id: true },
+ });
+
+ const honoured = member
+ ? await ctx.db!.query.membershipHistory.findFirst({
+ where: and(
+ eq(membershipHistory.memberId, member.id),
+ gte(membershipHistory.createdAt, existing.createdAt),
+ ),
+ columns: { id: true },
+ })
+ : undefined;
+
+ if (honoured) continue;
+
+ const parts = (user?.name || "Member").trim().split(/\s+/);
+ await createOrUpdateMembership(ctx.db! as DrizzleDB, {
+ userId: ctx.userId!,
+ firstName: parts[0] || "Member",
+ lastName: parts.slice(1).join(" ") || "Member",
+ bootcampMember: pi.metadata?.bootcamp === "true",
+ });
+ recovered += 1;
+ continue;
+ }
await ctx.db!.transaction(async (tx) => {
const claimed = await tx
diff --git a/packages/api/src/routers/team.ts b/packages/api/src/routers/team.ts
index bef558f1..dd0fd457 100644
--- a/packages/api/src/routers/team.ts
+++ b/packages/api/src/routers/team.ts
@@ -8,6 +8,7 @@ import {
hackathons,
} from "@query/db";
import { eq, and, or, isNull, inArray, lt, sql } from "drizzle-orm";
+import { VOLATILE_TTL } from "../middleware/cache";
import type { DrizzleDB } from "@query/db";
const HOUR = 60 * 60 * 1000;
@@ -41,6 +42,32 @@ export function computeTeamWindow(baseTime: Date, now: Date) {
};
}
+/**
+ * The submission window, as three moments and the state between them.
+ *
+ * Exported and used by `submitProject` itself, so the page and the procedure
+ * cannot disagree: /submit rendered no window state at all, which meant an
+ * attendee could write a full description and learn it was refused only when
+ * they pressed submit.
+ */
+export function computeSubmissionWindow(baseTime: Date, now: Date) {
+ const at = (hours: number) => new Date(baseTime.getTime() + hours * HOUR);
+
+ const opensAt = at(TEAM_WINDOW_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);
+
+ return {
+ opensAt,
+ editsCloseAt,
+ closesAt,
+ isOpen: now >= opensAt && now <= closesAt,
+ notYetOpen: now < opensAt,
+ canEditExisting: now >= opensAt && now <= editsCloseAt,
+ };
+}
+
async function loadTeamWindow(db: DrizzleDB, hackathonId: string) {
const hackathon = await db.query.hackathons.findFirst({
where: eq(hackathons.id, hackathonId),
@@ -560,6 +587,9 @@ export const teamRouter = createTRPCRouter({
technologies: z.array(z.string()).optional(),
tracks: z.array(z.string()).optional(),
challenges: z.array(z.string()).optional(),
+ // Judge routing filters on exactly this, and nothing else in the
+ // product ever set it — every CreateX judge got an empty pool.
+ isCreateX: z.boolean().optional(),
githubUrl: z
.string()
.url("Must be a valid URL")
@@ -621,15 +651,10 @@ export const teamRouter = createTRPCRouter({
const now = new Date();
const baseTime = hackathon.hackingStartTime ?? hackathon.startDate;
- const startSubmission = new Date(
- baseTime.getTime() + 12 * 60 * 60 * 1000,
- );
- const devpostFinalDeadline = new Date(
- baseTime.getTime() + 34 * 60 * 60 * 1000,
- );
- const hardDeadline = new Date(baseTime.getTime() + 36 * 60 * 60 * 1000);
+ const window = computeSubmissionWindow(baseTime, now);
+ const devpostFinalDeadline = window.editsCloseAt;
- if (now < startSubmission) {
+ if (window.notYetOpen) {
throw new TRPCError({
code: "FORBIDDEN",
message:
@@ -637,7 +662,7 @@ export const teamRouter = createTRPCRouter({
});
}
- if (now > hardDeadline) {
+ if (now > window.closesAt) {
throw new TRPCError({
code: "FORBIDDEN",
message:
@@ -718,10 +743,20 @@ export const teamRouter = createTRPCRouter({
technologies: input.technologies || [],
tracks: input.tracks || [],
challenges: input.challenges || [],
+ isCreateX: input.isCreateX ?? false,
githubUrl,
demoUrl,
videoUrl,
- status: "submitted",
+ // Only ever forward, never backwards. Writing "submitted"
+ // unconditionally let a team editing a demo link after
+ // promotion knock their project out of "judging" — which
+ // re-opened withdrawProject's status guard and let them
+ // withdraw a project judges were actively scoring.
+ status:
+ existingProject.status === "judging" ||
+ existingProject.status === "winner"
+ ? existingProject.status
+ : "submitted",
submittedAt: new Date(),
})
.where(eq(hackathonProjects.id, existingProject.id))
@@ -740,6 +775,7 @@ export const teamRouter = createTRPCRouter({
technologies: input.technologies || [],
tracks: input.tracks || [],
challenges: input.challenges || [],
+ isCreateX: input.isCreateX ?? false,
githubUrl,
demoUrl,
videoUrl,
@@ -863,37 +899,58 @@ export const teamRouter = createTRPCRouter({
return await loadTeamWindow(ctx.db as DrizzleDB, input.hackathonId);
}),
+ /**
+ * Every team in a hackathon.
+ *
+ * Deliberately NOT paginated. The Teams tab finds the caller's own team by
+ * searching this list, so with a page size any member of an early-created
+ * team would fall off page one and lose their entire "Your Team" panel,
+ * including Leave Team, with nothing on screen explaining why.
+ *
+ * Bounded by caching instead. The TTL is deliberately short: the tab
+ * refetches immediately after every join, leave and disband, and a long TTL
+ * served from another instance would show a roster the user just changed.
+ */
list: protectedProcedure
.input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") }))
.query(async ({ ctx, input }) => {
- const teams = await (
- ctx.db as NonNullable
- ).query.hackathonTeams.findMany({
- where: eq(hackathonTeams.hackathonId, input.hackathonId),
- with: {
- captain: {
- columns: { id: true, name: true, image: true },
- },
- participants: {
- // Same rule as the public hackathon.getTeams roster: any signed-in
- // caller can read every team here, so it carries neither the
- // decision made on each application — registrationStatus names
- // everyone rejected or waitlisted — nor the participant id, which
- // is the entire content of that participant's event pass QR.
- // userId identifies the captain and keys the list.
- columns: {
- userId: true,
+ const cacheKey = `hackathon:${input.hackathonId}:teams`;
+
+ const fetchTeams = () =>
+ (ctx.db as NonNullable).query.hackathonTeams.findMany({
+ where: eq(hackathonTeams.hackathonId, input.hackathonId),
+ with: {
+ captain: {
+ columns: { id: true, name: true, image: true },
},
- with: {
- user: {
- columns: { id: true, name: true, image: true },
+ participants: {
+ // Any signed-in caller can read every team here, so it carries
+ // neither the decision made on each application —
+ // registrationStatus names everyone rejected or waitlisted — nor
+ // the participant id, which is the entire content of that
+ // participant's event pass QR. userId identifies the captain and
+ // keys the list.
+ columns: {
+ userId: true,
+ },
+ with: {
+ user: {
+ columns: { id: true, name: true, image: true },
+ },
},
},
},
- },
- orderBy: (hackathonTeams, { desc }) => [desc(hackathonTeams.createdAt)],
- });
+ orderBy: (hackathonTeams, { desc }) => [
+ desc(hackathonTeams.createdAt),
+ ],
+ });
+
+ const cached =
+ ctx.cache.get>>(cacheKey);
+ if (cached !== null) return cached;
+ const teams = await fetchTeams();
+ ctx.cache.set(cacheKey, teams, VOLATILE_TTL);
return teams;
}),
@@ -903,6 +960,39 @@ export const teamRouter = createTRPCRouter({
* a solo hacker sees a blank form over a live submission, and saving a typo
* fix silently wipes the links they had already filed.
*/
+ /**
+ * The submission window for one edition, so /submit can say whether it is
+ * open before somebody fills the form in. Same computation the mutation
+ * enforces with, so the two cannot drift.
+ */
+ submissionWindow: protectedProcedure
+ .input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") }))
+ .query(async ({ ctx, input }) => {
+ const hackathon = await (ctx.db as DrizzleDB).query.hackathons.findFirst({
+ where: eq(hackathons.id, input.hackathonId),
+ columns: {
+ hackingStartTime: true,
+ startDate: true,
+ status: true,
+ },
+ });
+
+ if (!hackathon) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Hackathon not found.",
+ });
+ }
+
+ const baseTime = hackathon.hackingStartTime ?? hackathon.startDate;
+ const window = computeSubmissionWindow(baseTime, new Date());
+
+ return {
+ ...window,
+ cancelled: hackathon.status === "cancelled",
+ };
+ }),
+
mySubmission: protectedProcedure
.input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") }))
.query(async ({ ctx, input }) => {
diff --git a/packages/api/src/services/portal-context.ts b/packages/api/src/services/portal-context.ts
index 138ff3d6..a3932199 100644
--- a/packages/api/src/services/portal-context.ts
+++ b/packages/api/src/services/portal-context.ts
@@ -8,7 +8,7 @@ import {
import { eq, and } from "drizzle-orm";
import type { DrizzleDB } from "@query/db";
import { cache, clearMembershipCaches } from "../middleware/cache";
-import { EMPTY_MEMBER_CONTEXT } from "../types/portal-context";
+import { EMPTY_MEMBER_CONTEXT, isStaffRole } from "../types/portal-context";
import type { MemberContext, PortalContext } from "../types/portal-context";
const CURRENT_HACKATHON_KEY = "hackathon:current-id";
@@ -104,11 +104,10 @@ export async function fetchPortalContext(
db: DrizzleDB,
userId: string,
): Promise {
- const [admin, hackathonId, judgeRecord, leaderRecord] = await Promise.all([
+ const [admin, judgeRecord, leaderRecord] = await Promise.all([
db.query.admins.findFirst({
where: and(eq(admins.userId, userId), eq(admins.isActive, true)),
}),
- resolveHackathonId(db),
db.query.judges.findFirst({
where: and(eq(judges.userId, userId), eq(judges.isActive, true)),
columns: { id: true, name: true },
@@ -124,23 +123,21 @@ export async function fetchPortalContext(
}),
]);
- let member = EMPTY_MEMBER_CONTEXT;
-
- // Membership is still scoped to the edition, so it waits for one to resolve.
- if (hackathonId) {
- const memberRecord = await db.query.members.findFirst({
- where: and(
- eq(members.userId, userId),
- eq(members.hackathonId, hackathonId),
- ),
- });
- member = buildMemberContext(memberRecord ?? null);
- }
+ // 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;
return {
- isAdmin: !!admin,
+ // A volunteer holds an admins row but is not staff. Reporting them as
+ // admin here would render the whole admin nav for someone every one of
+ // those pages rejects.
+ isAdmin: isStaffRole(admin?.role),
+ isScanner: !!admin,
role: admin?.role ?? null,
permissions: admin?.permissions ?? [],
isJudge: !!judgeRecord,
@@ -148,8 +145,10 @@ export async function fetchPortalContext(
judgeName: judgeRecord?.name ?? null,
// Admins cover for leaders, and the middleware agrees — so the tab has to
// appear for them too or staff see a page they are allowed to use but
- // cannot reach.
- isProjectLeader: isProjectLeader || !!admin,
+ // cannot reach. isStaffRole, not `!!admin`: a volunteer holds an admins
+ // row but isProjectLeader (procedures.ts) rejects them, so the bare truthy
+ // check advertised /lead to the one role that cannot open it.
+ isProjectLeader: isProjectLeader || isStaffRole(admin?.role),
member,
};
}
diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts
index 9c21bc64..c62d0eae 100644
--- a/packages/api/src/trpc.ts
+++ b/packages/api/src/trpc.ts
@@ -250,7 +250,11 @@ const CACHE_INVALIDATION_MAP: Record = {
"hackathon:*:participants",
"hackathon:*:analytics",
],
- "hackathon.scanParticipantPass": ["hackathon:*:participants"],
+ // A badge scan changes one event's attendee count, not the roster. The
+ // resolver evicts that single key by id; an empty list here keeps the
+ // namespace fallback below from wiping every attendee's cached registrations
+ // on every scan, all weekend, at every door.
+ "hackathon.scanParticipantPass": [],
"hackathon.create": ["hackathons:list"],
"hackathon.update": ["hackathons:list", "hackathon:*"],
"hackathon.delete": ["hackathons:list", "hackathon:*"],
@@ -272,17 +276,63 @@ const CACHE_INVALIDATION_MAP: Record = {
"hackathon:*:rankings",
"hackathon:*:judge-analytics",
],
+ // Promotion creates judgeable projects and flips submissions to "judging",
+ // so both the public project list and the rankings view move.
+ "judge.promoteSubmissions": [
+ "hackathon:*:projects",
+ "hackathon:*:public-projects*",
+ "hackathon:*:rankings",
+ ],
+ // Announcements read the audience live and write nothing cacheable.
+ "hackathon.sendAnnouncement": [],
"judge.assignToHackathon": ["judge:*"],
// Member mutations
"member.update": ["member:*", "user:*:profile"],
// A renewal changes the membership the portal reads, so its context must go too
// Team mutations — team membership is embedded in both the public roster and
// each participant's own registration list
- "team.createTeam": ["hackathon:*:participants", "hackathon:registrations:*"],
- "team.joinTeam": ["hackathon:*:participants", "hackathon:registrations:*"],
- "team.leaveTeam": ["hackathon:*:participants", "hackathon:registrations:*"],
- "team.disbandTeam": ["hackathon:*:participants", "hackathon:registrations:*"],
- "team.submitProject": ["hackathon:*:projects", "hackathon:registrations:*"],
+ // team.list is cached now, and the tab refetches straight after each of
+ // these — so the roster key has to go with them or the user sees the state
+ // they just changed back again.
+ "team.createTeam": [
+ "hackathon:*:participants",
+ "hackathon:*:teams",
+ "hackathon:registrations:*",
+ ],
+ "team.joinTeam": [
+ "hackathon:*:participants",
+ "hackathon:*:teams",
+ "hackathon:registrations:*",
+ ],
+ "team.leaveTeam": [
+ "hackathon:*:participants",
+ "hackathon:*:teams",
+ "hackathon:registrations:*",
+ ],
+ "team.disbandTeam": [
+ "hackathon:*:participants",
+ "hackathon:*:teams",
+ "hackathon:registrations:*",
+ ],
+ // The public gallery is cached per page, so its keys carry a limit/offset
+ // suffix that a bare `:projects` pattern would not match.
+ "team.submitProject": [
+ "hackathon:*:projects",
+ "hackathon:*:public-projects*",
+ "hackathon:registrations:*",
+ ],
+ "team.withdrawProject": [
+ "hackathon:*:projects",
+ "hackathon:*:public-projects*",
+ ],
+ // Both evict precisely by id in the resolver; empty keeps the namespace
+ // fallback from sweeping every attendee's cached registrations.
+ "hackathon.adminUpdateProject": [],
+ "hackathon.adminWithdrawProject": [],
+ // Publishing and unpublishing change what the public getResults returns.
+ "judge.computeResults": ["hackathon:*:results"],
+ "judge.publishResults": ["hackathon:*:results"],
+ "judge.unpublishResults": ["hackathon:*:results"],
// Stripe — invalidate member status after linking
"stripe.attemptAutoLink": ["member:*"],
"stripe.linkAccount": ["member:*"],
@@ -331,12 +381,17 @@ export const publicProcedure = t.procedure
.use(sanitizeInputs)
.use(enforceContentType)
.use(async ({ ctx, next, type }) => {
- // DDoS Protection - check IP-based limits first
- const ddosCheck = ddosProtection(ctx.clientIp);
+ // Flood protection. Key on the signed-in user when there is one: at a
+ // 2000-person venue every attendee shares one NAT address, so an
+ // address-keyed bucket blocks the whole building the moment the schedule
+ // page gets popular. Prefixes keep the two namespaces from colliding.
+ const ddosCheck = ddosProtection(
+ ctx.userId ? `user:${ctx.userId}` : `ip:${ctx.clientIp}`,
+ );
if (!ddosCheck.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
- message: `Too many requests from your IP. Please try again in ${ddosCheck.retryAfter} seconds.`,
+ message: `Too many requests. Please try again in ${ddosCheck.retryAfter} seconds.`,
});
}
diff --git a/packages/api/src/types/portal-context.ts b/packages/api/src/types/portal-context.ts
index e40d5541..b545efce 100644
--- a/packages/api/src/types/portal-context.ts
+++ b/packages/api/src/types/portal-context.ts
@@ -16,8 +16,22 @@ export type MemberContext = {
renewalCount: number;
};
+/**
+ * Full staff, as opposed to a volunteer.
+ *
+ * Lives here rather than beside the middleware because both the middleware and
+ * the portal context need it, and procedures.ts already imports from the
+ * portal-context service — putting it there would close an import cycle.
+ */
+export const isStaffRole = (role: string | null | undefined) =>
+ !!role && role !== "volunteer";
+
export type PortalContext = {
+ /** Full staff. False for volunteers, who hold an admins row but are limited
+ * to badge scanning. */
isAdmin: boolean;
+ /** Any active admins row, volunteers included — may staff a check-in desk. */
+ isScanner: boolean;
role: string | null;
permissions: string[];
isJudge: boolean;
diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts
index 449f220e..e91ff584 100644
--- a/packages/auth/src/config.ts
+++ b/packages/auth/src/config.ts
@@ -180,16 +180,21 @@ export const authConfig: NextAuthConfig = {
error: "/auth/error",
},
callbacks: {
+ /**
+ * Deliberately does no database work beyond what the adapter already did.
+ *
+ * With the database session strategy this callback runs on every single
+ * request, so anything queried here is queried once per request per user.
+ * A judge lookup used to live here to set `session.user.isJudge` — with
+ * 2000 attendees, none of whom are judges, that was a second connection
+ * checkout per request across the whole fleet.
+ *
+ * Judge status is read from `user.getPortalContext` (cached) and
+ * `judge.isJudge` instead, which is where every consumer already gets it.
+ */
async session({ session, user }) {
- if (user && session.user && db) {
+ if (user && session.user) {
session.user.id = user.id;
-
- // Add judge status to session for easier client-side checks
- const judge = await db.query.judges.findFirst({
- where: (j, { eq }) => eq(j.userId, user.id),
- });
- // @ts-expect-error - custom property
- session.user.isJudge = !!judge;
}
return session;
},
diff --git a/packages/auth/src/email.ts b/packages/auth/src/email.ts
index 5a359a93..f41f0967 100644
--- a/packages/auth/src/email.ts
+++ b/packages/auth/src/email.ts
@@ -1,4 +1,138 @@
import nodemailer from "nodemailer";
+import type { Transporter } from "nodemailer";
+
+/**
+ * One pooled transporter for the process, built on first use.
+ *
+ * `pool: true` only does anything if the transporter outlives the message.
+ * Built per call it was worse than useless: every recipient paid a fresh
+ * TCP + TLS + AUTH handshake and left a pool behind to be garbage collected.
+ * A mass acceptance send is thousands of messages, so that is the difference
+ * between a batch that finishes and one that times out.
+ *
+ * Lazily created so importing this module never requires SMTP config —
+ * the send path is the only thing that needs it.
+ */
+let transporter: Transporter | null = null;
+
+const getTransporter = () => {
+ if (!transporter) {
+ transporter = nodemailer.createTransport({
+ host: process.env.EMAIL_SERVER_HOST,
+ port: Number(process.env.EMAIL_SERVER_PORT || "587"),
+ auth: {
+ user: process.env.EMAIL_SERVER_USER,
+ pass: process.env.EMAIL_SERVER_PASSWORD,
+ },
+ pool: true,
+ // Deliberately env-tunable. A consumer Gmail account tolerates far less
+ // than a bulk provider, and the same code has to serve both: point
+ // EMAIL_SERVER_* at Mailgun/SendGrid/SES and raise these, no redeploy of
+ // anything but config.
+ maxConnections: Number(process.env.EMAIL_MAX_CONNECTIONS || "5"),
+ maxMessages: Number(process.env.EMAIL_MAX_MESSAGES || "100"),
+ });
+ }
+ return transporter;
+};
+
+const escapeHtml = (value: string) =>
+ value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+
+/**
+ * The shared shell every transactional message uses, so an announcement looks
+ * like it came from the same organisation as the acceptance.
+ */
+const renderShell = ({
+ heading,
+ bodyHtml,
+ ctaLabel,
+ ctaUrl,
+}: {
+ heading: string;
+ bodyHtml: string;
+ ctaLabel?: string;
+ ctaUrl?: string;
+}) => {
+ const mainColor = "#10b981";
+ const backgroundColor = "#0f172a";
+ const textColor = "#f8fafc";
+
+ const cta =
+ ctaLabel && ctaUrl
+ ? `${escapeHtml(ctaLabel)} `
+ : "";
+
+ return `
+
+
+
+
+
+
+
+
+
DataScienceGT
+
+ ${escapeHtml(heading)}
+ ${bodyHtml}
+ ${cta}
+
+
+
+
+ © ${new Date().getFullYear()} Data Science at Georgia Tech
+
+
+
+
+ `;
+};
+
+/**
+ * One announcement to one recipient — "registration is open", "schedule is
+ * live", "results are up".
+ *
+ * `body` is plain text written by an organiser in the admin panel. It is
+ * escaped and then newline-split into paragraphs: treating it as HTML would
+ * make the compose box an injection point into thousands of inboxes.
+ */
+export async function sendAnnouncementEmail({
+ email,
+ subject,
+ heading,
+ body,
+ ctaLabel,
+ ctaUrl,
+}: {
+ email: string;
+ subject: string;
+ heading: string;
+ body: string;
+ ctaLabel?: string;
+ ctaUrl?: string;
+}) {
+ const bodyHtml = body
+ .split(/\n{2,}/)
+ .map(
+ (paragraph) =>
+ `${escapeHtml(paragraph).replace(/\n/g, " ")}
`,
+ )
+ .join("");
+
+ await getTransporter().sendMail({
+ from: process.env.EMAIL_FROM || "noreply@datasciencegt.org",
+ to: email,
+ subject,
+ text: body,
+ html: renderShell({ heading, bodyHtml, ctaLabel, ctaUrl }),
+ });
+}
export async function sendAcceptanceEmail({
email,
@@ -9,16 +143,6 @@ export async function sendAcceptanceEmail({
hackathonName: string;
host?: string;
}) {
- const transporter = nodemailer.createTransport({
- host: process.env.EMAIL_SERVER_HOST,
- port: Number(process.env.EMAIL_SERVER_PORT || "587"),
- auth: {
- user: process.env.EMAIL_SERVER_USER,
- pass: process.env.EMAIL_SERVER_PASSWORD,
- },
- pool: true,
- });
-
const mainColor = "#10b981";
const backgroundColor = "#0f172a";
const textColor = "#f8fafc";
@@ -69,7 +193,7 @@ export async function sendAcceptanceEmail({