From 6618ac57a8ede762af3a129316fb4f8b4a29f4e6 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Wed, 5 Aug 2026 12:38:27 -0400
Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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);