diff --git a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts index 15205f5a..e4fefad7 100644 --- a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts +++ b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts @@ -69,6 +69,7 @@ vi.mock("@query/db", () => { stripePayments: table("stripePayments"), userAccountLinks: table("userAccountLinks"), auditLogs: table("auditLogs"), + hackathonBans: table("hackathonBans"), }, insert: (...insertArgs: any[]) => ({ values: (...valArgs: any[]) => { @@ -124,6 +125,12 @@ vi.mock("@query/db", () => { role: "role", }, users: { _t: "users", id: "id", email: "email" }, + hackathonBans: { + _t: "hackathonBans", + id: "id", + email: "email", + createdAt: "created_at", + }, userProfiles: { _t: "userProfiles", userId: "user_id" }, hackathons: { _t: "hackathons", @@ -500,6 +507,80 @@ describe("Hackathon admin management edge cases", () => { }); }); + // ===================================================================== + describe("Hackathon bans", () => { + const BANNED = "Banned.Person@Example.com"; + + // A signed-in participant whose email comes from their user row, the + // path taken when the session carries no email. + const participant = (banned: boolean) => { + mockFindFirst.mockImplementation((table: string) => { + if (table === "users") return { email: BANNED }; + if (table === "hackathonBans") return banned ? { id: "ban-1" } : undefined; + return undefined; + }); + return appRouter.createCaller(createMockCtx("participant-user")); + }; + + it("refuses a banned person every way of taking part", async () => { + const caller = participant(true); + const refused = /can't take part in Hacklytics/; + + await expect( + caller.hackathon.registerInterest({ hackathonId: HACK_A }), + ).rejects.toThrow(refused); + await expect( + caller.team.joinTeam({ inviteCode: "ABCDEF" } as never), + ).rejects.toThrow(refused); + await expect( + caller.team.createTeam({ hackathonId: HACK_A, name: "Team" } as never), + ).rejects.toThrow(refused); + }); + + it("lets everyone else through the ban gate", async () => { + const caller = participant(false); + const result = await caller.hackathon + .registerInterest({ hackathonId: HACK_A }) + .catch((e: Error) => e); + expect(String(result)).not.toMatch(/can't take part/); + }); + + it("stores the ban by lowercased email and audits it", async () => { + const caller = adminCaller({}, "admin"); + mockInsert.mockReturnValue([{ id: "ban-1" }]); + + await caller.hackathon.banFromHackathons({ + email: BANNED, + reason: "Harassment at Hacklytics 2026", + }); + + const values = mockInsert.mock.calls.map((c) => c[2]?.[0]); + expect(values).toContainEqual( + expect.objectContaining({ email: "banned.person@example.com" }), + ); + expect(values).toContainEqual( + expect.objectContaining({ action: "hackathon.ban" }), + ); + }); + + it("refuses a bug tester banning or lifting", async () => { + const caller = adminCaller({}, "bug_tester"); + + await expect( + caller.hackathon.banFromHackathons({ email: BANNED, reason: "Test" }), + ).rejects.toThrow(/read-only/); + await expect( + caller.hackathon.liftHackathonBan({ email: BANNED }), + ).rejects.toThrow(/read-only/); + }); + + it("refuses a non-staff account the ban list", async () => { + await expect(participant(false).hackathon.listBans()).rejects.toThrow( + /Admin access required/, + ); + }); + }); + const liveHackathon = (overrides: Record = {}) => ({ id: HACK_A, name: "Hacklytics 2027", diff --git a/packages/api/src/middleware/procedures.ts b/packages/api/src/middleware/procedures.ts index 751e2ce0..99539cfe 100644 --- a/packages/api/src/middleware/procedures.ts +++ b/packages/api/src/middleware/procedures.ts @@ -2,6 +2,8 @@ import { TRPCError } from "@trpc/server"; import { protectedProcedure } from "../trpc"; import { admins, + hackathonBans, + users, judges, judgingProjects, judgeQueue, @@ -261,3 +263,51 @@ export const isJudge = protectedProcedure.use(async ({ ctx, next, getRawInput }) return next({ ctx: { ...ctx, judge } }); }, ); + +/** Cache key for a hackathon-ban lookup. Shared with the ban/unban mutations. */ +export const hackathonBanCacheKey = (email: string) => + `hackathon-ban:${email.trim().toLowerCase()}`; + +// Refuses people banned from hackathons. Goes on the procedures where someone +// takes part — registering, the interest list, teams, submitting, applying to +// judge — and not on reads or on the ways out (withdraw, leave), so a banned +// person can still see and undo what they already did. The ban is by email, +// so it holds across providers and new accounts on the same address. Cached +// 60s, the negative answer too; ban and unban clear the key. +export const notHackathonBanned = protectedProcedure.use( + async ({ ctx, next }) => { + const db = ctx.db as NonNullable; + + let email = ctx.session?.user?.email ?? null; + if (!email) { + const user = await db.query.users.findFirst({ + where: eq(users.id, ctx.userId as string), + columns: { email: true }, + }); + email = user?.email ?? null; + } + + if (email) { + const key = hackathonBanCacheKey(email); + let banned = ctx.cache.get(key); + if (banned === null) { + const ban = await db.query.hackathonBans.findFirst({ + where: eq(hackathonBans.email, email.trim().toLowerCase()), + columns: { id: true }, + }); + banned = !!ban; + ctx.cache.set(key, banned, 60); + } + + if (banned) { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "You can't take part in Hacklytics events. If you think this is a mistake, email hello@hacklytics.io.", + }); + } + } + + return next({ ctx }); + }, +); diff --git a/packages/api/src/routers/hackathon/bans.ts b/packages/api/src/routers/hackathon/bans.ts new file mode 100644 index 00000000..9c49aaff --- /dev/null +++ b/packages/api/src/routers/hackathon/bans.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; +import { desc, eq, inArray } from "drizzle-orm"; +import { hackathonBans, users } from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import { createTRPCRouter } from "../../trpc"; +import { hackathonBanCacheKey, isAdmin } from "../../middleware/procedures"; +import { recordAdminAction } from "../../middleware/audit"; + +const normalize = (email: string) => email.trim().toLowerCase(); + +/** + * Hackathon bans. Staff can bar someone from taking part in hackathons + * (registering, the interest list, teams, submitting, judging) without + * touching their club membership. Enforcement is notHackathonBanned in + * middleware/procedures.ts; this router only manages the list. Bug testers + * can read it and nothing else, like every isAdmin mutation. + */ +export const hackathonBansRouter = createTRPCRouter({ + listBans: isAdmin.query(async ({ ctx }) => { + const db = ctx.db as DrizzleDB; + const bans = await db.query.hackathonBans.findMany({ + orderBy: [desc(hackathonBans.createdAt)], + }); + + // Names for the people involved, in one read rather than one per row. + const emails = bans.map((b) => b.email); + const staffIds = bans + .map((b) => b.bannedBy) + .filter((id): id is string => !!id); + const [accounts, staff] = await Promise.all([ + emails.length + ? db.query.users.findMany({ + where: inArray(users.email, emails), + columns: { email: true, name: true }, + }) + : [], + staffIds.length + ? db.query.users.findMany({ + where: inArray(users.id, staffIds), + columns: { id: true, name: true, email: true }, + }) + : [], + ]); + + return bans.map((ban) => { + const account = accounts.find((a) => normalize(a.email) === ban.email); + const by = staff.find((s) => s.id === ban.bannedBy); + return { + id: ban.id, + email: ban.email, + name: account?.name ?? null, + hasAccount: !!account, + reason: ban.reason, + bannedBy: by?.name ?? by?.email ?? null, + createdAt: ban.createdAt, + }; + }); + }), + + banFromHackathons: isAdmin + .input( + z.object({ + email: z.string().trim().email().max(320), + reason: z.string().trim().min(3).max(500), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const email = normalize(input.email); + + // Re-banning updates the reason instead of failing on the unique email. + const [ban] = await db + .insert(hackathonBans) + .values({ email, reason: input.reason, bannedBy: ctx.userId }) + .onConflictDoUpdate({ + target: hackathonBans.email, + set: { reason: input.reason, bannedBy: ctx.userId }, + }) + .returning(); + + ctx.cache.delete(hackathonBanCacheKey(email)); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "hackathon.ban", + resourceId: email, + severity: "warn", + metadata: { reason: input.reason }, + }); + + return ban; + }), + + liftHackathonBan: isAdmin + .input(z.object({ email: z.string().trim().email().max(320) })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const email = normalize(input.email); + + const removed = await db + .delete(hackathonBans) + .where(eq(hackathonBans.email, email)) + .returning({ id: hackathonBans.id }); + + ctx.cache.delete(hackathonBanCacheKey(email)); + + if (removed.length) { + await recordAdminAction(db, { + userId: ctx.userId, + action: "hackathon.unban", + resourceId: email, + }); + } + + return { lifted: removed.length > 0 }; + }), +}); diff --git a/packages/api/src/routers/hackathon/index.ts b/packages/api/src/routers/hackathon/index.ts index ebc1aec2..65aa781d 100644 --- a/packages/api/src/routers/hackathon/index.ts +++ b/packages/api/src/routers/hackathon/index.ts @@ -6,6 +6,7 @@ import { hackathonEventsRouter } from "./events"; import { hackathonContentRouter } from "./content"; import { hackathonInterestRouter } from "./interest"; import { hackathonAnnounceRouter } from "./announce"; +import { hackathonBansRouter } from "./bans"; export const hackathonRouter = mergeRouters( hackathonCrudRouter, @@ -15,4 +16,5 @@ export const hackathonRouter = mergeRouters( hackathonContentRouter, hackathonInterestRouter, hackathonAnnounceRouter, + hackathonBansRouter, ); diff --git a/packages/api/src/routers/hackathon/interest.ts b/packages/api/src/routers/hackathon/interest.ts index f37be48f..f8c91bbd 100644 --- a/packages/api/src/routers/hackathon/interest.ts +++ b/packages/api/src/routers/hackathon/interest.ts @@ -23,7 +23,7 @@ import { protectedProcedure, publicProcedure, } from "../../trpc"; -import { isAdmin } from "../../middleware/procedures"; +import { isAdmin, notHackathonBanned } from "../../middleware/procedures"; import { rateLimit } from "../../middleware/security"; import { VOLATILE_TTL } from "../../middleware/cache"; @@ -154,7 +154,7 @@ export const hackathonInterestRouter = createTRPCRouter({ // Upserted, so submitting twice edits one entry rather than failing on the // unique index or quietly creating a second. Somebody correcting their // graduation year should not have to find a delete button. - registerInterest: protectedProcedure + registerInterest: notHackathonBanned .input(interestInput) .mutation(async ({ ctx, input }) => { const db = ctx.db as DrizzleDB; diff --git a/packages/api/src/routers/hackathon/registration.ts b/packages/api/src/routers/hackathon/registration.ts index e4db769a..aa85b860 100644 --- a/packages/api/src/routers/hackathon/registration.ts +++ b/packages/api/src/routers/hackathon/registration.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; +import { notHackathonBanned } from "../../middleware/procedures"; import { CacheKeys } from "../../middleware/cache"; import { hackathons, @@ -37,7 +38,7 @@ const isDuplicateRegistration = (error: unknown) => { }; export const hackathonRegistrationRouter = createTRPCRouter({ - register: protectedProcedure + register: notHackathonBanned .input( z.object({ hackathonId: z.string().uuid("Invalid hackathon ID"), diff --git a/packages/api/src/routers/judge/admin.ts b/packages/api/src/routers/judge/admin.ts index 1a11b025..bfa2e79d 100644 --- a/packages/api/src/routers/judge/admin.ts +++ b/packages/api/src/routers/judge/admin.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { createTRPCRouter, protectedProcedure } from "../../trpc"; +import { createTRPCRouter } from "../../trpc"; import { judges, judgeAssignments, @@ -13,7 +13,11 @@ import { hackathonParticipants, } from "@query/db"; import { eq, and, asc, sql, inArray, isNull } from "drizzle-orm"; -import { isAdmin, isSuperAdmin } from "../../middleware/procedures"; +import { + isAdmin, + isSuperAdmin, + notHackathonBanned, +} from "../../middleware/procedures"; import { CacheKeys, invalidatePortalContext } from "../../middleware/cache"; import type { DrizzleDB } from "@query/db"; import { isLive, loadGroups, loadPool } from "./dispatch"; @@ -911,7 +915,7 @@ export const judgeAdminRouter = createTRPCRouter({ return result; }), - register: protectedProcedure + register: notHackathonBanned .input( z.object({ hackathonId: z.string().uuid(), diff --git a/packages/api/src/routers/team.ts b/packages/api/src/routers/team.ts index ee243934..0e036a1e 100644 --- a/packages/api/src/routers/team.ts +++ b/packages/api/src/routers/team.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { notHackathonBanned } from "../middleware/procedures"; import { hackathonTeams, hackathonParticipants, @@ -211,7 +212,7 @@ async function assertNoLiveSoloSubmission( } export const teamRouter = createTRPCRouter({ - createTeam: protectedProcedure + createTeam: notHackathonBanned .input( z.object({ hackathonId: z.string().uuid("Invalid hackathon ID"), @@ -305,7 +306,7 @@ export const teamRouter = createTRPCRouter({ } }), - joinTeam: protectedProcedure + joinTeam: notHackathonBanned .input( z.object({ hackathonId: z.string().uuid("Invalid hackathon ID"), @@ -663,7 +664,7 @@ export const teamRouter = createTRPCRouter({ } }), - submitProject: protectedProcedure + submitProject: notHackathonBanned .input( z.object({ hackathonId: z.string().uuid(), diff --git a/packages/db/src/schemas/hackathons.ts b/packages/db/src/schemas/hackathons.ts index a85b6546..0ada7f93 100644 --- a/packages/db/src/schemas/hackathons.ts +++ b/packages/db/src/schemas/hackathons.ts @@ -584,3 +584,22 @@ export const hackathonAnnouncementRecipientsRelations = relations( }), }), ); + +// People barred from taking part in hackathons: registering, the interest +// list, teams, submitting and judging. Club membership is untouched. Keyed by +// lowercased email rather than user id, so signing in again through another +// provider, or with a new account on the same address, is still refused. +// Lifting a ban deletes the row; who banned and lifted whom is in audit_logs. +export const hackathonBans = pgTable( + "hackathon_ban", + { + id: uuid("id").defaultRandom().primaryKey(), + email: text("email").notNull(), + reason: text("reason").notNull(), + bannedBy: text("banned_by").references(() => users.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [uniqueIndex("hackathon_ban_email_idx").on(table.email)], +); diff --git a/sites/mainweb/app/(portal)/admin/hackathons/page.tsx b/sites/mainweb/app/(portal)/admin/hackathons/page.tsx index f21e0692..4b8c3902 100644 --- a/sites/mainweb/app/(portal)/admin/hackathons/page.tsx +++ b/sites/mainweb/app/(portal)/admin/hackathons/page.tsx @@ -14,6 +14,7 @@ import { body, btnPrimary, btnSecondary, pageDek } from "@/components/portal/ui" import { HackathonCard } from "@/components/admin/hackathons/HackathonCard"; import { CreateHackathonForm } from "@/components/admin/hackathons/CreateHackathonForm"; import { EditHackathonForm } from "@/components/admin/hackathons/EditHackathonForm"; +import { HackathonBans } from "@/components/admin/hackathons/HackathonBans"; const adminTitle = "font-[family-name:var(--font-display)] text-[32px] md:text-[40px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text-primary)]"; @@ -146,6 +147,8 @@ export default function AdminHackathonsPage() { })} )} + + diff --git a/sites/mainweb/components/admin/hackathons/HackathonBans.tsx b/sites/mainweb/components/admin/hackathons/HackathonBans.tsx new file mode 100644 index 00000000..922eef2e --- /dev/null +++ b/sites/mainweb/components/admin/hackathons/HackathonBans.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useState } from "react"; +import { trpc } from "@/lib/trpc"; +import { READ_ONLY_TITLE, useReadOnly } from "@/lib/use-portal-context"; +import { + body, + btnDanger, + btnSecondary, + fieldHint, + fieldLabel, + input, + meta, + sectionRule, + sectionTitle, +} from "@/components/portal/ui"; + +/** + * People barred from hackathons. A ban stops registering, the interest list, + * teams, submitting and applying to judge; club membership is untouched. It + * is keyed by email, so it also covers an account they create later. + */ +export function HackathonBans() { + const utils = trpc.useUtils(); + const readOnly = useReadOnly(); + const [email, setEmail] = useState(""); + const [reason, setReason] = useState(""); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + const bans = trpc.hackathon.listBans.useQuery(); + + const ban = trpc.hackathon.banFromHackathons.useMutation({ + onSuccess: (_row, vars) => { + setError(null); + setNotice(`${vars.email} can no longer take part in hackathons.`); + setEmail(""); + setReason(""); + utils.hackathon.listBans.invalidate(); + }, + onError: (e) => { + setNotice(null); + setError(e.message); + }, + }); + + const lift = trpc.hackathon.liftHackathonBan.useMutation({ + onSuccess: (_res, vars) => { + setError(null); + setNotice(`Ban lifted for ${vars.email}.`); + utils.hackathon.listBans.invalidate(); + }, + onError: (e) => { + setNotice(null); + setError(e.message); + }, + }); + + const canBan = + !readOnly && + !ban.isPending && + email.trim().length > 3 && + reason.trim().length >= 3; + + return ( +
+

Banned from hackathons

+

+ A ban stops someone registering, joining the interest list, forming or + joining a team, submitting and applying to judge. Their club membership + is not affected. It applies to the email address, including accounts + they make later. +

+ +
{ + e.preventDefault(); + if (!canBan) return; + if ( + !window.confirm( + `Ban ${email.trim()} from all hackathons? They will be refused at registration, teams and submission.`, + ) + ) + return; + ban.mutate({ email: email.trim(), reason: reason.trim() }); + }} + > +
+ + setEmail(e.target.value)} + placeholder="name@gatech.edu" + className={input} + /> +
+
+ + setReason(e.target.value)} + maxLength={500} + placeholder="What happened, for the record" + className={input} + /> +
+ +
+

+ Staff see the reason. The person sees only that they can't take + part. +

+ + {error && ( +

+ {error} +

+ )} + {notice && ( +

+ {notice} +

+ )} + +
+ {bans.isLoading ? ( +

Loading bans…

+ ) : bans.error ? ( +

+ The ban list didn't load. Reload the page to try again. +

+ ) : !bans.data?.length ? ( +

Nobody is banned from hackathons.

+ ) : ( +
    + {bans.data.map((b) => ( +
  • +
    +

    + {b.name ? `${b.name} · ` : ""} + {b.email} +

    +

    {b.reason}

    +

    + Banned{" "} + {new Date(b.createdAt).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + })} + {b.bannedBy ? ` by ${b.bannedBy}` : ""} + {b.hasAccount ? "" : " · no account yet"} +

    +
    + +
  • + ))} +
+ )} +
+
+ ); +}