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/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..8f7f6b96 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 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..7ba1282c 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,61 @@ export const isSuperAdmin = isAdmin.use(async ({ ctx, next }) => { return next({ ctx }); }); +/** + * 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 + * 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 cacheKey = `${CacheKeys.projectLeader(userId)}:role`; + let leader = ctx.cache.get(cacheKey); + + if (!leader) { + leader = + (await db.query.projectLeaders.findFirst({ + where: and( + eq(projectLeaders.userId, userId), + 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, + 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/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 new file mode 100644 index 00000000..3d88f7b4 --- /dev/null +++ b/packages/api/src/routers/initiative.ts @@ -0,0 +1,1009 @@ +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; + +/** + * 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() + .default(DEFAULT_TEAM_SIZE - 1), +}); + +/** + * 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; 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. + * + * 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 && + 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( + // 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), + ), + ) + .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 + /** + * `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 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.isActive, true), + ), + columns: { id: true }, + }); + if (!target) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "That person is not a project leader.", + }); + } + 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({ + leaderUserId, + 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) => { + // 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(); + + 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.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); + + 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.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 + * 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) => { + // 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(); + + // 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(); + } + + await requireActiveMember(tx, userId); + + 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.", + }); + } + + 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 }; + }), + + // --------------------------------------------------------------- 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; + + 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. + const [waiting] = await db + .select({ total: count() }) + .from(initiatives) + .where( + and( + 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({ + 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; + + 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(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; + + 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(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: eq(projectLeaders.userId, proposal.leaderUserId), + }); + + 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, + 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; + + 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)) + .orderBy(asc(users.email)) + .limit(200); + }), + + /** + * 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 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: eq(projectLeaders.userId, input.userId), + }); + + 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, + 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/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 0d6e75c5..138ff3d6 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 { @@ -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, @@ -87,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)), }), @@ -96,10 +113,20 @@ 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; + // Membership is still scoped to the edition, so it waits for one to resolve. if (hackathonId) { const memberRecord = await db.query.members.findFirst({ where: and( @@ -110,6 +137,8 @@ export async function fetchPortalContext( member = buildMemberContext(memberRecord ?? null); } + const isProjectLeader = !!leaderRecord; + return { isAdmin: !!admin, role: admin?.role ?? null, @@ -117,6 +146,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..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": [ @@ -282,6 +286,21 @@ 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:*"], + "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 b62a62fc..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; @@ -14,12 +23,15 @@ 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; }; 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/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/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..57bf89c3 --- /dev/null +++ b/packages/db/src/schemas/initiatives.ts @@ -0,0 +1,204 @@ +import { + pgTable, + text, + timestamp, + uuid, + boolean, + integer, + index, + unique, +} from "drizzle-orm/pg-core"; +import { relations } from "drizzle-orm"; +import { users } from "./auth"; + +/** + * 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. + * + * 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", + { + id: uuid("id").defaultRandom().primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.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), + unique("unique_project_leader").on(table.userId), + ], +); + +export type ProjectLeader = typeof projectLeaders.$inferSelect; + +/** + * 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 + * 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(), + 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"), + /** + * 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. */ + 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(), + }, + (table) => [ + 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] }), +})); + +export const initiativesRelations = relations(initiatives, ({ one, many }) => ({ + leader: one(users, { + fields: [initiatives.leaderUserId], + references: [users.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/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 new file mode 100644 index 00000000..c904385b --- /dev/null +++ b/sites/mainweb/app/(portal)/admin/initiatives/page.tsx @@ -0,0 +1,288 @@ +"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"; +import type { RouterOutputs } from "@query/api"; + +/** + * 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 + * 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}`} +

+
+ +
+ + +
+
+ + {/* 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 && ( +
+ +