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 ( +
+
+
+ +

Project leaders

+
+

+ A project leader can post initiatives and pick who joins them. It + grants nothing else — admin screens stay admin-only. +

+
+ + +
{ + event.preventDefault(); + if (!userId.trim()) return; + setLeader.mutate({ userId: userId.trim(), isLeader: true }); + }} + > +
+ + setUserId(event.target.value)} + placeholder="User id from the attendees list" + 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" + /> +
+ +
+ + {setLeader.error && ( +

{setLeader.error.message}

+ )} +
+ +
+

+ Leaders this edition +

+ + {rows.length > 0 ? ( +
+ {rows.map((leader) => ( + +
+
+

+ {leader.name ?? leader.email} + {!leader.isActive && ( + + revoked + + )} +

+

+ {leader.email} +

+
+ + +
+
+ ))} +
+ ) : ( + +

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 && ( + + ) + )} +
+ + {initiative.summary && ( +

{initiative.summary}

+ )} + {initiative.description && ( +

+ {initiative.description} +

+ )} + + {!canApply && !initiative.isFull && ( +

+ An active membership is required to join.{" "} + + Become a member + +

+ )} + + {writing && ( +
{ + event.preventDefault(); + join.mutate({ + initiativeId: initiative.id, + pitch: pitch.trim() || undefined, + }); + }} + > + +