diff --git a/packages/api/src/.internal-tests/announcements.test.ts b/packages/api/src/.internal-tests/announcements.test.ts new file mode 100644 index 00000000..c6250d01 --- /dev/null +++ b/packages/api/src/.internal-tests/announcements.test.ts @@ -0,0 +1,336 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { appRouter } from "../root"; +import { cache } from "../middleware/cache"; + +/** + * Mass announcements, and the thing that makes them survivable: a per-recipient + * marker. + * + * The send loop runs in an organiser's browser and walks thousands of + * recipients across separate requests. Before this, the server re-resolved the + * audience on every batch and the client sliced it by offset — so a closed tab + * could not be resumed without mailing everybody again, and any row that moved + * between requests shifted the window silently. + */ + +const mockFindFirst = vi.fn(); +const mockFindMany = vi.fn(); +const mockInsert = vi.fn(); +const mockUpdate = vi.fn(); +const mockSelectRows = vi.fn(() => [] as unknown[]); +const mockSendAnnouncement = vi.fn(); + +vi.mock("@query/auth/email", () => ({ + sendAnnouncementEmail: (...args: unknown[]) => mockSendAnnouncement(...args), +})); + +vi.mock("@query/db", () => { + const selectChain = () => { + const node: any = { + from: () => node, + innerJoin: () => node, + leftJoin: () => node, + where: () => node, + groupBy: () => 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: (...args: any[]) => mockFindMany(name, ...args), + }); + + return { + db: { + query: { + admins: table("admins"), + users: table("users"), + hackathons: table("hackathons"), + hackathonAnnouncements: table("hackathonAnnouncements"), + hackathonAnnouncementRecipients: table( + "hackathonAnnouncementRecipients", + ), + 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), + }); + }, + }), + 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), + }); + }, + }), + }), + }, + admins: { userId: "user_id", isActive: "is_active", role: "role" }, + users: { id: "id", name: "name", email: "email" }, + hackathons: { id: "id", status: "status", isPublic: "is_public" }, + members: { userId: "user_id" }, + projectLeaders: { userId: "user_id", isActive: "is_active" }, + judges: { userId: "user_id", isActive: "is_active" }, + hackathonInterest: { + id: "id", + hackathonId: "hackathon_id", + userId: "user_id", + }, + hackathonParticipants: { + id: "id", + hackathonId: "hackathon_id", + userId: "user_id", + registrationStatus: "registration_status", + }, + hackathonAnnouncements: { + id: "id", + hackathonId: "hackathon_id", + subject: "subject", + audience: "audience", + createdAt: "created_at", + }, + hackathonAnnouncementRecipients: { + id: "id", + announcementId: "announcement_id", + userId: "user_id", + email: "email", + sentAt: "sent_at", + failedAt: "failed_at", + }, + }; +}); + +import { db } from "@query/db"; + +const HACK = "33333333-3333-4333-8333-333333333333"; +const ANNOUNCEMENT = "44444444-4444-4444-8444-444444444444"; +const ADMIN = "user_admin"; +const VISITOR = "user_visitor"; + +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); + +const asAdmin = (extra: (table: string) => unknown = () => undefined) => + mockFindFirst.mockImplementation((table: string) => { + if (table === "admins") return { id: "ad_1", role: "admin", isActive: true }; + return extra(table); + }); + +const compose = { + hackathonId: HACK, + audience: "interested" as const, + subject: "Registration is open", + heading: "Registration is open", + body: "Applications close soon.", +}; + +describe("Announcements", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFindFirst.mockReset(); + mockFindMany.mockReset().mockReturnValue([]); + mockInsert.mockReset().mockReturnValue([{ id: ANNOUNCEMENT }]); + mockUpdate.mockReset().mockReturnValue([]); + mockSelectRows.mockReset().mockReturnValue([]); + mockSendAnnouncement.mockReset().mockResolvedValue(undefined); + cache.clear(); + }); + + describe("Composing", () => { + it("freezes the audience into recipient rows and sends nothing", async () => { + asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined)); + mockSelectRows.mockReturnValue([ + { userId: "u1", email: "ada@example.com" }, + { userId: "u2", email: "grace@example.com" }, + ]); + + const res = await callerFor(ADMIN).hackathon.createAnnouncement(compose); + + expect(res.totalRecipients).toBe(2); + // Composing must not mail anyone: the batches are a separate call, which + // is what makes the send resumable at all. + expect(mockSendAnnouncement).not.toHaveBeenCalled(); + + const recipientRows = mockInsert.mock.calls + .map((c) => c[2]?.[0]) + .find((rows) => Array.isArray(rows)) as Record[]; + expect(recipientRows).toHaveLength(2); + expect(recipientRows[0]).toMatchObject({ + announcementId: ANNOUNCEMENT, + email: "ada@example.com", + }); + }); + + it("deduplicates one person appearing twice in an audience", async () => { + asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined)); + mockSelectRows.mockReturnValue([ + { userId: "u1", email: "ada@example.com" }, + { userId: "u1_other_row", email: "ada@example.com" }, + ]); + + const res = await callerFor(ADMIN).hackathon.createAnnouncement(compose); + expect(res.totalRecipients).toBe(1); + }); + + // A button with a label and no link renders dead; a link with no label + // renders nothing. Both are only visible once they are in an inbox. + it("refuses half a call-to-action", async () => { + asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined)); + + await expect( + callerFor(ADMIN).hackathon.createAnnouncement({ + ...compose, + ctaLabel: "Apply now", + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("refuses an audience with nobody in it", async () => { + asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined)); + mockSelectRows.mockReturnValue([]); + + await expect( + callerFor(ADMIN).hackathon.createAnnouncement(compose), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("is refused to a caller who is not an admin", async () => { + mockFindFirst.mockImplementation(() => undefined); + + await expect( + callerFor(VISITOR).hackathon.createAnnouncement(compose), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + }); + + describe("Sending in batches", () => { + const announcement = { + id: ANNOUNCEMENT, + hackathonId: HACK, + subject: "Registration is open", + heading: "Registration is open", + body: "Applications close soon.", + ctaLabel: null, + ctaUrl: null, + }; + + /** + * The batch is CLAIMED with one atomic update before anything is sent — + * two overlapping requests would otherwise both select the same + * `sent_at IS NULL` rows and both mail them. + */ + it("claims the batch, then marks every recipient as it sends", async () => { + asAdmin((table) => + table === "hackathonAnnouncements" ? announcement : undefined, + ); + mockUpdate.mockReturnValueOnce([ + { id: "r1", email: "ada@example.com" }, + { id: "r2", email: "grace@example.com" }, + ]); + mockSelectRows.mockReturnValue([{ count: 0 }]); + + const res = await callerFor(ADMIN).hackathon.sendBatch({ + announcementId: ANNOUNCEMENT, + }); + + expect(res).toMatchObject({ sent: 2, remaining: 0, done: true }); + expect(mockSendAnnouncement).toHaveBeenCalledTimes(2); + // One claim, then one stamp per recipient — stamping once at the end + // would lose the resume marker for everyone already mailed. + expect(mockUpdate).toHaveBeenCalledTimes(3); + expect(mockUpdate.mock.calls[0]![2][0].claimedAt).toBeInstanceOf(Date); + expect(mockUpdate.mock.calls[1]![2][0].sentAt).toBeInstanceOf(Date); + }); + + it("reports itself unfinished while recipients remain", async () => { + asAdmin((table) => + table === "hackathonAnnouncements" ? announcement : undefined, + ); + mockUpdate.mockReturnValueOnce([{ id: "r1", email: "ada@example.com" }]); + mockSelectRows.mockReturnValue([{ count: 499 }]); + + const res = await callerFor(ADMIN).hackathon.sendBatch({ + announcementId: ANNOUNCEMENT, + }); + + expect(res.done).toBe(false); + expect(res.remaining).toBe(499); + }); + + /** + * A rejected address is marked failed, not sent: retried on every batch it + * would stall the loop forever, and marked sent it would be indistinguishable + * from a delivery. + */ + it("records a rejected address separately from a delivered one", async () => { + asAdmin((table) => + table === "hackathonAnnouncements" ? announcement : undefined, + ); + mockUpdate.mockReturnValueOnce([ + { id: "r1", email: "bounces@example.com" }, + { id: "r2", email: "grace@example.com" }, + ]); + mockSelectRows.mockReturnValue([{ count: 0 }]); + mockSendAnnouncement.mockRejectedValueOnce(new Error("550 rejected")); + + const res = await callerFor(ADMIN).hackathon.sendBatch({ + announcementId: ANNOUNCEMENT, + }); + + expect(res.sent).toBe(1); + expect(res.failed).toEqual(["bounces@example.com"]); + const stamped = mockUpdate.mock.calls.slice(1).map((c) => c[2][0]); + expect(stamped.some((row) => "failedAt" in row)).toBe(true); + expect(stamped.some((row) => "sentAt" in row)).toBe(true); + }); + + // Reopening a finished send must not re-mail its audience. + it("sends nothing when no recipient is pending", async () => { + asAdmin((table) => + table === "hackathonAnnouncements" ? announcement : undefined, + ); + mockUpdate.mockReturnValue([]); + mockSelectRows.mockReturnValue([{ count: 0 }]); + + const res = await callerFor(ADMIN).hackathon.sendBatch({ + announcementId: ANNOUNCEMENT, + }); + + expect(res).toMatchObject({ sent: 0, done: true }); + expect(mockSendAnnouncement).not.toHaveBeenCalled(); + }); + + it("answers NOT_FOUND for an announcement that does not exist", async () => { + asAdmin(); + + await expect( + callerFor(ADMIN).hackathon.sendBatch({ + announcementId: ANNOUNCEMENT, + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + }); +}); diff --git a/packages/api/src/.internal-tests/hackathon-interest.test.ts b/packages/api/src/.internal-tests/hackathon-interest.test.ts index 21ab2b14..6c319def 100644 --- a/packages/api/src/.internal-tests/hackathon-interest.test.ts +++ b/packages/api/src/.internal-tests/hackathon-interest.test.ts @@ -15,7 +15,14 @@ import { hackathonInterest } from "@query/db"; const mockFindFirst = vi.fn(); const mockInsert = vi.fn(); const mockDelete = vi.fn(); +const mockUpdate = vi.fn(); const mockSelectRows = vi.fn(() => [] as unknown[]); +const mockSendRegistrationOpen = vi.fn(); + +vi.mock("@query/auth/email", () => ({ + sendRegistrationOpenEmail: (...args: unknown[]) => + mockSendRegistrationOpen(...args), +})); vi.mock("@query/db", () => { const selectChain = () => { @@ -69,6 +76,16 @@ vi.mock("@query/db", () => { }); }, }), + 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), + }); + }, + }), + }), }, admins: { userId: "user_id", isActive: "is_active", role: "role" }, users: { id: "id", name: "name", email: "email" }, @@ -149,7 +166,9 @@ describe("Hackathon interest list", () => { mockFindFirst.mockReset(); mockInsert.mockReset().mockReturnValue([]); mockDelete.mockReset().mockReturnValue([]); + mockUpdate.mockReset().mockReturnValue([]); mockSelectRows.mockReset().mockReturnValue([]); + mockSendRegistrationOpen.mockReset().mockResolvedValue(undefined); cache.clear(); }); @@ -167,6 +186,124 @@ describe("Hackathon interest list", () => { lookups({ hackathon: undefined }); await expect(callerFor().hackathon.getUpcoming()).resolves.toBeNull(); }); + + /** + * /hacklytics is the only public entrance to the hackathon — the 2027 + * site's single CTA and the navbar both land there. Filtering to + * `announced` alone meant the page went blank the moment registration + * opened, which is the moment it matters most. + */ + it("still renders once registration opens, and says so", async () => { + lookups({ hackathon: announced({ status: "open" }) }); + + const res = await callerFor().hackathon.getUpcoming(); + expect(res?.name).toBe("Example Hackathon"); + expect(res?.registrationOpen).toBe(true); + }); + + it("reports an announced edition as not yet open", async () => { + lookups({ hackathon: announced() }); + + const res = await callerFor().hackathon.getUpcoming(); + expect(res?.registrationOpen).toBe(false); + }); + }); + + describe("5. Telling the list registration opened", () => { + /** + * The list exists for this one moment and nothing sent it — the runbook + * told organisers to hand-compose an announcement instead. + */ + /** + * Claimed with one atomic update before anything is sent: two overlapping + * requests would otherwise both select the same pending rows and both mail + * them. + */ + it("claims, emails everyone pending, and marks each one as it goes", async () => { + lookups({ hackathon: announced({ status: "open" }), isAdmin: true }); + mockUpdate.mockReturnValueOnce([{ id: "int_1" }, { id: "int_2" }]); + mockSelectRows + // The claim's own subquery is built (and this mock's `limit` resolves + // eagerly) before the recipient lookup runs. + .mockReturnValueOnce([]) + .mockReturnValueOnce([ + { id: "int_1", email: "ada@example.com" }, + { id: "int_2", email: "grace@example.com" }, + ]) + .mockReturnValue([{ count: 0 }]); + + const res = await callerFor(ADMIN).hackathon.notifyRegistrationOpen({ + hackathonId: HACK, + }); + + expect(res).toMatchObject({ sent: 2, done: true }); + expect(mockSendRegistrationOpen).toHaveBeenCalledTimes(2); + // One claim, then one marker per recipient — a marker written once at the + // end would leave a closed tab re-mailing everyone already reached. + expect(mockUpdate).toHaveBeenCalledTimes(3); + expect( + mockUpdate.mock.calls[0]![2][0].registrationOpenEmailClaimedAt, + ).toBeInstanceOf(Date); + expect( + mockUpdate.mock.calls[1]![2][0].registrationOpenEmailSentAt, + ).toBeInstanceOf(Date); + }); + + // Everyone who acts on the mail would land on a closed registration page. + it("refuses while registration is still closed", async () => { + lookups({ hackathon: announced({ status: "announced" }), isAdmin: true }); + + await expect( + callerFor(ADMIN).hackathon.notifyRegistrationOpen({ + hackathonId: HACK, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(mockSendRegistrationOpen).not.toHaveBeenCalled(); + }); + + // A rejected address must not stop the rest of the batch, and must not be + // marked as sent — otherwise it is silently never retried. + /** + * A rejected address is marked failed rather than left pending: left + * pending it is retried on every batch and the send can never report + * itself finished. + */ + it("keeps going when one address is rejected, and records the failure", async () => { + lookups({ hackathon: announced({ status: "open" }), isAdmin: true }); + mockUpdate.mockReturnValueOnce([{ id: "int_1" }, { id: "int_2" }]); + mockSelectRows + .mockReturnValueOnce([]) + .mockReturnValueOnce([ + { id: "int_1", email: "bounces@example.com" }, + { id: "int_2", email: "grace@example.com" }, + ]) + .mockReturnValue([{ count: 0 }]); + mockSendRegistrationOpen.mockRejectedValueOnce(new Error("550 rejected")); + + const res = await callerFor(ADMIN).hackathon.notifyRegistrationOpen({ + hackathonId: HACK, + }); + + expect(res.sent).toBe(1); + expect(res.failed).toEqual(["bounces@example.com"]); + const written = mockUpdate.mock.calls.slice(1).map((c) => c[2][0]); + expect( + written.some((row) => "registrationOpenEmailFailedAt" in row), + ).toBe(true); + expect(written.some((row) => "registrationOpenEmailSentAt" in row)).toBe( + true, + ); + }); + + it("is refused to a caller who is not an admin", async () => { + lookups({ hackathon: announced({ status: "open" }), isAdmin: false }); + + await expect( + callerFor(VISITOR).hackathon.notifyRegistrationOpen({ + hackathonId: HACK, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); }); describe("2. Which editions take interest", () => { diff --git a/packages/api/src/routers/hackathon/announce.ts b/packages/api/src/routers/hackathon/announce.ts index ec9813a0..46e9588d 100644 --- a/packages/api/src/routers/hackathon/announce.ts +++ b/packages/api/src/routers/hackathon/announce.ts @@ -1,7 +1,20 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { and, eq, inArray, isNotNull } from "drizzle-orm"; import { + and, + asc, + desc, + eq, + inArray, + isNotNull, + isNull, + lt, + or, + sql, +} from "drizzle-orm"; +import { + hackathonAnnouncements, + hackathonAnnouncementRecipients, hackathonInterest, hackathonParticipants, hackathons, @@ -17,9 +30,15 @@ import { isAdmin } from "../../middleware/procedures"; * * Kept separate from sendMassAcceptanceEmails because the two differ in the * thing that matters — an acceptance also changes a participant's status and - * must be exactly once, while an announcement writes nothing and is safe to - * repeat. Sharing one procedure would have meant one set of guarantees serving - * two jobs badly. + * must be exactly once, while an announcement writes nothing about the person + * it is about. Sharing one procedure would have meant one set of guarantees + * serving two jobs badly. + * + * Composing and sending are two steps. `createAnnouncement` freezes the message + * and its audience into rows; `sendBatch` walks the un-sent ones. That split is + * what makes a send resumable: the loop runs in an organiser's browser, and + * before this a closed tab left no record of who had already been mailed — + * re-running it mailed everyone again. */ /** Recipients per request. See MASS_EMAIL_BATCH on the client: each one is an @@ -27,6 +46,15 @@ import { isAdmin } from "../../middleware/procedures"; * Run's timeout. */ const MAX_RECIPIENTS_PER_CALL = 500; +/** + * How long a claimed-but-unsent recipient stays claimed. + * + * Long enough that a batch still working through its 500 SMTP round trips is + * never reclaimed underneath itself, short enough that a request killed by a + * deploy does not strand its rows for the rest of the event. + */ +const CLAIM_TIMEOUT_MS = 15 * 60 * 1000; + const AUDIENCES = [ "interested", "registered", @@ -40,8 +68,8 @@ type Audience = (typeof AUDIENCES)[number]; * Everyone in the chosen audience, as `{ userId, email }`. * * Email is read from the users table rather than stored alongside the interest - * or participant row, so a person who changes their address gets the mail at - * the address they actually use. + * or participant row, so the address is the one the person actually uses — it + * is then copied onto the recipient row, freezing the audience at compose time. */ const resolveAudience = async ( db: DrizzleDB, @@ -49,7 +77,7 @@ const resolveAudience = async ( audience: Audience, ) => { if (audience === "interested") { - const rows = await db + return await db .select({ userId: hackathonInterest.userId, email: users.email }) .from(hackathonInterest) .innerJoin(users, eq(users.id, hackathonInterest.userId)) @@ -58,8 +86,8 @@ const resolveAudience = async ( eq(hackathonInterest.hackathonId, hackathonId), isNotNull(users.email), ), - ); - return rows; + ) + .orderBy(asc(hackathonInterest.userId)); } // "registered" is everyone holding a seat, whatever stage they are at. @@ -82,7 +110,8 @@ const resolveAudience = async ( inArray(hackathonParticipants.registrationStatus, [...statuses]), isNotNull(users.email), ), - ); + ) + .orderBy(asc(hackathonParticipants.userId)); }; export const hackathonAnnounceRouter = createTRPCRouter({ @@ -96,14 +125,60 @@ export const hackathonAnnounceRouter = createTRPCRouter({ const entries = await Promise.all( AUDIENCES.map(async (audience) => { const rows = await resolveAudience(db, input.hackathonId, audience); - return [audience, rows.length] as const; + const emails = new Set(rows.map((r) => r.email)); + return [audience, emails.size] as const; }), ); return Object.fromEntries(entries) as Record; }), - sendAnnouncement: isAdmin + /** + * Announcements for this edition and how far each one got — so a reopened tab + * can pick an unfinished send back up instead of starting a duplicate. + */ + listAnnouncements: isAdmin + .input(z.object({ hackathonId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const rows = await db + .select({ + id: hackathonAnnouncements.id, + subject: hackathonAnnouncements.subject, + audience: hackathonAnnouncements.audience, + createdAt: hackathonAnnouncements.createdAt, + total: sql`count(${hackathonAnnouncementRecipients.id})::int`, + sent: sql`count(${hackathonAnnouncementRecipients.sentAt})::int`, + failed: sql`count(${hackathonAnnouncementRecipients.failedAt})::int`, + }) + .from(hackathonAnnouncements) + .leftJoin( + hackathonAnnouncementRecipients, + eq( + hackathonAnnouncementRecipients.announcementId, + hackathonAnnouncements.id, + ), + ) + .where(eq(hackathonAnnouncements.hackathonId, input.hackathonId)) + .groupBy(hackathonAnnouncements.id) + .orderBy(desc(hackathonAnnouncements.createdAt)) + .limit(50); + + return rows.map((row) => ({ + ...row, + pending: row.total - row.sent - row.failed, + })); + }), + + /** + * Freezes a message and its audience. Sends nothing. + * + * The recipient rows written here are the resume marker: `sendBatch` only + * ever looks at rows with no `sentAt`, so a batch that never ran, a tab that + * was closed and a second click all converge on the same remaining set. + */ + createAnnouncement: isAdmin .input( z.object({ hackathonId: z.string().uuid(), @@ -113,9 +188,6 @@ export const hackathonAnnounceRouter = createTRPCRouter({ body: z.string().trim().min(1).max(5000), ctaLabel: z.string().trim().max(60).optional(), ctaUrl: z.string().url().max(500).optional(), - /** Skip this many recipients. The client walks the audience in batches - * and reports progress; the server stays one bounded unit of work. */ - offset: z.number().int().min(0).default(0), }), ) .mutation(async ({ ctx, input }) => { @@ -123,7 +195,7 @@ export const hackathonAnnounceRouter = createTRPCRouter({ const hackathon = await db.query.hackathons.findFirst({ where: eq(hackathons.id, input.hackathonId), - columns: { id: true, name: true }, + columns: { id: true }, }); if (!hackathon) { @@ -154,30 +226,167 @@ export const hackathonAnnounceRouter = createTRPCRouter({ return true; }); - const batch = recipients.slice( - input.offset, - input.offset + MAX_RECIPIENTS_PER_CALL, - ); + if (recipients.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "That audience has nobody in it.", + }); + } + + const [announcement] = await db + .insert(hackathonAnnouncements) + .values({ + hackathonId: input.hackathonId, + audience: input.audience, + subject: input.subject, + heading: input.heading, + body: input.body, + ctaLabel: input.ctaLabel ?? null, + ctaUrl: input.ctaUrl ?? null, + createdById: ctx.userId as string, + }) + .returning({ id: hackathonAnnouncements.id }); + + if (!announcement) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Could not create the announcement", + }); + } + + for (let i = 0; i < recipients.length; i += 1000) { + await db.insert(hackathonAnnouncementRecipients).values( + recipients.slice(i, i + 1000).map((row) => ({ + announcementId: announcement.id, + userId: row.userId, + email: row.email as string, + })), + ); + } + + return { + announcementId: announcement.id, + totalRecipients: recipients.length, + }; + }), + + /** + * Sends the next batch of an announcement. Call until `done`. + * + * Each recipient is marked the moment their send returns, so nothing depends + * on the caller keeping count — the client's offset arithmetic used to be the + * only thing standing between a dropped connection and a second delivery to + * everyone already reached. + */ + sendBatch: isAdmin + .input(z.object({ announcementId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const announcement = await db.query.hackathonAnnouncements.findFirst({ + where: eq(hackathonAnnouncements.id, input.announcementId), + }); + + if (!announcement) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Announcement not found", + }); + } + + /** + * Claim the batch before sending a single message. + * + * Selecting `sent_at IS NULL` and marking afterwards left a window: two + * overlapping requests — two organisers, or one impatient double-click — + * both read the same rows and both sent to them. The claim is a single + * atomic UPDATE, so exactly one request wins each row and the loser gets + * a smaller batch rather than a duplicate delivery. + * + * A claim older than CLAIM_TIMEOUT_MS is reclaimable: a request that died + * mid-flight (timeout, deploy, crash) would otherwise leave its rows + * claimed forever and the send permanently unfinishable. The window is + * generous — re-sending to somebody is worse than making an organiser + * wait — and only a batch that genuinely stopped can hit it. + */ + const claimCutoff = new Date(Date.now() - CLAIM_TIMEOUT_MS); + + const pending = await db + .update(hackathonAnnouncementRecipients) + .set({ claimedAt: new Date() }) + .where( + and( + eq( + hackathonAnnouncementRecipients.announcementId, + input.announcementId, + ), + isNull(hackathonAnnouncementRecipients.sentAt), + // A previously rejected address is left alone rather than retried + // on every batch, which would stall the loop on a permanent + // failure. + isNull(hackathonAnnouncementRecipients.failedAt), + or( + isNull(hackathonAnnouncementRecipients.claimedAt), + lt(hackathonAnnouncementRecipients.claimedAt, claimCutoff), + ), + inArray( + hackathonAnnouncementRecipients.id, + db + .select({ id: hackathonAnnouncementRecipients.id }) + .from(hackathonAnnouncementRecipients) + .where( + and( + eq( + hackathonAnnouncementRecipients.announcementId, + input.announcementId, + ), + isNull(hackathonAnnouncementRecipients.sentAt), + isNull(hackathonAnnouncementRecipients.failedAt), + or( + isNull(hackathonAnnouncementRecipients.claimedAt), + lt(hackathonAnnouncementRecipients.claimedAt, claimCutoff), + ), + ), + ) + .orderBy(asc(hackathonAnnouncementRecipients.id)) + .limit(MAX_RECIPIENTS_PER_CALL), + ), + ), + ) + .returning({ + id: hackathonAnnouncementRecipients.id, + email: hackathonAnnouncementRecipients.email, + }); const { sendAnnouncementEmail } = await import("@query/auth/email"); let sent = 0; const failed: string[] = []; - for (const recipient of batch) { - if (!recipient.email) continue; + for (const recipient of pending) { try { await sendAnnouncementEmail({ email: recipient.email, - subject: input.subject, - heading: input.heading, - body: input.body, - ctaLabel: input.ctaLabel, - ctaUrl: input.ctaUrl, + subject: announcement.subject, + heading: announcement.heading, + body: announcement.body, + ctaLabel: announcement.ctaLabel ?? undefined, + ctaUrl: announcement.ctaUrl ?? undefined, }); + + await db + .update(hackathonAnnouncementRecipients) + .set({ sentAt: new Date() }) + .where(eq(hackathonAnnouncementRecipients.id, recipient.id)); + sent++; } catch (error) { failed.push(recipient.email); + await db + .update(hackathonAnnouncementRecipients) + .set({ failedAt: new Date() }) + .where(eq(hackathonAnnouncementRecipients.id, recipient.id)); + // Deliberate server-side operational logging: this is the only // record of which address the provider rejected. // eslint-disable-next-line no-console @@ -188,14 +397,25 @@ export const hackathonAnnounceRouter = createTRPCRouter({ } } - const nextOffset = input.offset + batch.length; + const [remaining] = await db + .select({ count: sql`count(*)::int` }) + .from(hackathonAnnouncementRecipients) + .where( + and( + eq( + hackathonAnnouncementRecipients.announcementId, + input.announcementId, + ), + isNull(hackathonAnnouncementRecipients.sentAt), + isNull(hackathonAnnouncementRecipients.failedAt), + ), + ); return { sent, failed, - totalRecipients: recipients.length, - nextOffset, - done: nextOffset >= recipients.length, + remaining: remaining?.count ?? 0, + done: (remaining?.count ?? 0) === 0, }; }), }); diff --git a/packages/api/src/routers/hackathon/interest.ts b/packages/api/src/routers/hackathon/interest.ts index 1c0ba62e..25be66fd 100644 --- a/packages/api/src/routers/hackathon/interest.ts +++ b/packages/api/src/routers/hackathon/interest.ts @@ -1,6 +1,17 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { and, asc, desc, eq } from "drizzle-orm"; +import { + and, + asc, + desc, + eq, + inArray, + isNotNull, + isNull, + lt, + or, + sql, +} from "drizzle-orm"; import { hackathonInterest, hackathons, users } from "@query/db"; import type { DrizzleDB } from "@query/db"; import { @@ -34,15 +45,33 @@ const interestInput = z.object({ const blankToNull = (value: string | undefined) => value && value.length > 0 ? value : null; +/** Recipients per request — one SMTP round trip each, and a request carrying + * more than this does not finish inside Cloud Run's timeout. Matches + * announce.ts. */ +const MAX_RECIPIENTS_PER_CALL = 500; + +/** Same reasoning as announce.ts: a claim this old belonged to a batch that + * died, and must be reclaimable or the send can never finish. */ +const CLAIM_TIMEOUT_MS = 15 * 60 * 1000; + /** - * 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. + * The edition the landing page is about. + * + * `open` and `in_progress` belong here, not just `announced`: /hacklytics is + * the only public entrance — the 2027 site's single CTA and the navbar both + * land on it — and filtering to `announced` alone meant that the moment an + * organiser opened registration, the one page telling the world about the + * hackathon said "Nothing announced yet". + * + * Soonest first, so announcing the year after next does not displace the one + * being promoted now. */ +const PUBLIC_FUNNEL_STATUSES = ["announced", "open", "in_progress"] as const; + async function findAnnounced(db: DrizzleDB) { return db.query.hackathons.findFirst({ where: and( - eq(hackathons.status, "announced"), + inArray(hackathons.status, [...PUBLIC_FUNNEL_STATUSES]), eq(hackathons.isPublic, true), ), orderBy: asc(hackathons.startDate), @@ -70,6 +99,12 @@ export const hackathonInterestRouter = createTRPCRouter({ endDate: upcoming.endDate, theme: upcoming.theme, websiteUrl: upcoming.websiteUrl, + // The page shows an interest form or a register CTA off this: the two + // states are the same edition at different moments, not different pages. + status: upcoming.status, + registrationOpen: + upcoming.status === "open" || upcoming.status === "in_progress", + registrationDeadline: upcoming.registrationDeadline, }; }), @@ -157,6 +192,202 @@ export const hackathonInterestRouter = createTRPCRouter({ return { onList: false }; }), + /** + * How many people are waiting to be told registration opened, and how many + * already were — so the admin screen can offer the send, and say what it + * would do, before anything leaves. + */ + registrationOpenEmailStatus: isAdmin + .input(z.object({ hackathonId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const [counts] = await db + .select({ + total: sql`count(*)::int`, + sent: sql`count(${hackathonInterest.registrationOpenEmailSentAt})::int`, + failed: sql`count(${hackathonInterest.registrationOpenEmailFailedAt})::int`, + }) + .from(hackathonInterest) + .innerJoin(users, eq(users.id, hackathonInterest.userId)) + .where( + and( + eq(hackathonInterest.hackathonId, input.hackathonId), + isNotNull(users.email), + ), + ); + + const total = counts?.total ?? 0; + const sent = counts?.sent ?? 0; + const failed = counts?.failed ?? 0; + return { total, sent, failed, pending: total - sent - failed }; + }), + + /** + * Tells the interest list that registration opened. + * + * The list exists for this one moment and nothing sent it — organisers had to + * hand-compose an announcement, and the runbook said so. Marked per recipient + * before the next one is attempted, so a closed tab, a timeout or an + * impatient second click resumes rather than mailing anyone twice. + */ + notifyRegistrationOpen: isAdmin + .input( + z.object({ + hackathonId: z.string().uuid(), + /** Where the CTA points. Defaults to the public funnel page. */ + registerUrl: z.string().url().max(500).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const hackathon = await db.query.hackathons.findFirst({ + where: eq(hackathons.id, input.hackathonId), + columns: { id: true, name: true, status: true }, + }); + + if (!hackathon) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Hackathon not found", + }); + } + + // Telling the list to go and register while registration is shut is the + // one failure this message cannot recover from — everyone who acts on it + // lands on a closed page. + if (hackathon.status !== "open" && hackathon.status !== "in_progress") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Registration is not open for this hackathon yet, so there is nothing to announce.", + }); + } + + /** + * Claim before sending, exactly as announce.ts does. + * + * Reading the pending rows and marking them afterwards left a window in + * which two overlapping requests both selected the same people and both + * mailed them. The claim is one atomic UPDATE, so only one request wins + * each row; a claim older than CLAIM_TIMEOUT_MS is reclaimable so a + * request that died mid-batch does not strand its recipients. + */ + const claimCutoff = new Date(Date.now() - CLAIM_TIMEOUT_MS); + + const claimable = db + .select({ id: hackathonInterest.id }) + .from(hackathonInterest) + .innerJoin(users, eq(users.id, hackathonInterest.userId)) + .where( + and( + eq(hackathonInterest.hackathonId, input.hackathonId), + isNull(hackathonInterest.registrationOpenEmailSentAt), + isNull(hackathonInterest.registrationOpenEmailFailedAt), + or( + isNull(hackathonInterest.registrationOpenEmailClaimedAt), + lt(hackathonInterest.registrationOpenEmailClaimedAt, claimCutoff), + ), + isNotNull(users.email), + ), + ) + .orderBy(asc(hackathonInterest.id)) + .limit(MAX_RECIPIENTS_PER_CALL); + + const claimed = await db + .update(hackathonInterest) + .set({ registrationOpenEmailClaimedAt: new Date() }) + .where(inArray(hackathonInterest.id, claimable)) + .returning({ + id: hackathonInterest.id, + userId: hackathonInterest.userId, + }); + + // The address is read from the users table so somebody who changed it + // still gets the mail; the claim above is keyed on the interest row. + const recipients = await db + .select({ id: hackathonInterest.id, email: users.email }) + .from(hackathonInterest) + .innerJoin(users, eq(users.id, hackathonInterest.userId)) + .where( + inArray( + hackathonInterest.id, + claimed.map((row) => row.id), + ), + ); + + const pending = claimed.length > 0 ? recipients : []; + + const { sendRegistrationOpenEmail } = await import("@query/auth/email"); + + let sent = 0; + const failed: string[] = []; + + for (const row of pending) { + if (!row.email) continue; + try { + await sendRegistrationOpenEmail({ + email: row.email, + hackathonName: hackathon.name, + registerUrl: input.registerUrl, + }); + + // Stamped immediately after the send, not in a batch at the end: a + // crash half-way through otherwise re-mails everyone already reached. + await db + .update(hackathonInterest) + .set({ registrationOpenEmailSentAt: new Date() }) + .where(eq(hackathonInterest.id, row.id)); + + sent++; + } catch (error) { + failed.push(row.email); + // Marked failed rather than left pending. Left pending, a permanently + // bad address is re-attempted on every batch and the send can never + // report itself finished. + await db + .update(hackathonInterest) + .set({ registrationOpenEmailFailedAt: new Date() }) + .where(eq(hackathonInterest.id, row.id)); + // Deliberate server-side operational logging: this is the only record + // of which address the provider rejected. + // eslint-disable-next-line no-console + console.error( + `[Email Service] Registration-open notice failed for ${row.email}:`, + error, + ); + } + } + + /** + * Counted, not inferred from the batch size. + * + * `pending.length < MAX` reported "done" while recipients that had just + * failed were still unsent — and with failures now marked, the only + * honest answer is what the table says is left. + */ + const [remaining] = await db + .select({ count: sql`count(*)::int` }) + .from(hackathonInterest) + .innerJoin(users, eq(users.id, hackathonInterest.userId)) + .where( + and( + eq(hackathonInterest.hackathonId, input.hackathonId), + isNull(hackathonInterest.registrationOpenEmailSentAt), + isNull(hackathonInterest.registrationOpenEmailFailedAt), + isNotNull(users.email), + ), + ); + + return { + sent, + failed, + remaining: remaining?.count ?? 0, + done: (remaining?.count ?? 0) === 0, + }; + }), + /** * 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. diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index c62d0eae..12d0372a 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -97,6 +97,21 @@ const isPlainObject = (value: object) => { * arrays or plain objects (Date, Buffer, …) are handed on as-is so the * procedure's own validator still sees them. */ +/** + * Ceiling on any array reaching a procedure, checked before zod runs. + * + * Must stay at or above the largest bound any input schema declares, or that + * schema is unreachable: this throws "Array too large" first, so a procedure + * advertising `.max(2500)` would reject at 501 with a message that names + * neither the real limit nor the field. It sat at 500 while + * batchUpdateParticipantStatus allowed 2500, which made approving a + * 2000-person roster impossible in a single call. + * + * This is not the payload guard — scrubbing an array of uuids is linear and + * cheap. Request size is bounded separately by validateRequestSize. + */ +const MAX_ARRAY_LENGTH = 2500; + const scrubMarkup = (input: unknown, depth = 0): unknown => { if (depth > 10) { throw new TRPCError({ @@ -125,7 +140,7 @@ const scrubMarkup = (input: unknown, depth = 0): unknown => { } if (Array.isArray(input)) { - if (input.length > 500) { + if (input.length > MAX_ARRAY_LENGTH) { throw new TRPCError({ code: "BAD_REQUEST", message: "Array too large" }); } return input.map((item) => scrubMarkup(item, depth + 1)); @@ -283,8 +298,14 @@ const CACHE_INVALIDATION_MAP: Record = { "hackathon:*:public-projects*", "hackathon:*:rankings", ], - // Announcements read the audience live and write nothing cacheable. - "hackathon.sendAnnouncement": [], + // Announcements write their own rows and nothing cacheable. Empty rather than + // absent, so neither one falls through to sweeping the whole hackathon + // namespace — which includes every attendee's cached registrations. + "hackathon.createAnnouncement": [], + "hackathon.sendBatch": [], + // Same: the marker lives on hackathon_interest, which is not cached, and the + // admin list is read fresh. + "hackathon.notifyRegistrationOpen": [], "judge.assignToHackathon": ["judge:*"], // Member mutations "member.update": ["member:*", "user:*:profile"], @@ -329,6 +350,12 @@ const CACHE_INVALIDATION_MAP: Record = { // fallback from sweeping every attendee's cached registrations. "hackathon.adminUpdateProject": [], "hackathon.adminWithdrawProject": [], + // Both evict precisely in the resolver. Left unmapped they fall through to + // deletePattern("hackathon:*"), which also matches every attendee's cached + // registrations and the rankings entry — and removeEventAttendance is + // reachable by a volunteer pressing Undo at a check-in desk. + "hackathon.removeEventAttendance": [], + "hackathon.sendMassAcceptanceEmails": [], // Publishing and unpublishing change what the public getResults returns. "judge.computeResults": ["hackathon:*:results"], "judge.publishResults": ["hackathon:*:results"], @@ -474,16 +501,15 @@ export const uploadProcedure = t.procedure .use(enforceContentType) .use(cacheInvalidationMiddleware); -export const judgeProcedure = t.procedure - .use(requiresDb) - .use(isAuthed) - .use(sanitizeInputs) - .use(enforceContentType) - .use(cacheInvalidationMiddleware); - -export const adminProcedure = t.procedure - .use(requiresDb) - .use(isAuthed) - .use(sanitizeInputs) - .use(enforceContentType) - .use(cacheInvalidationMiddleware); +/* + * There is deliberately no `adminProcedure` or `judgeProcedure` here. + * + * Both used to exist and were byte-for-byte identical to `protectedProcedure` — + * no role check of any kind. Writing `adminProcedure.mutation(...)`, which is + * the obvious thing to reach for, shipped an admin endpoint open to every + * signed-in user, and it typechecked, linted and built cleanly. Neither had a + * single caller, so the names existed only to be misused. + * + * The real gates live in middleware/procedures.ts: `isAdmin` (full staff), + * `isSuperAdmin`, `isScanner` (volunteers included) and `isJudge`. Use those. + */ diff --git a/packages/auth/src/email.ts b/packages/auth/src/email.ts index f41f0967..5561a9c3 100644 --- a/packages/auth/src/email.ts +++ b/packages/auth/src/email.ts @@ -94,13 +94,66 @@ const renderShell = ({ `; }; +const DEFAULT_HOST = "https://datasciencegt.org"; + +/** + * Every message this product sends, in one shape. + * + * `paragraphs` is plain text — always. Each one is escaped and wrapped, so no + * template can turn a name, a hackathon title or an organiser's compose box + * into markup in thousands of inboxes. A template that needs a link says so + * with `ctaUrl`, not by writing an anchor. + */ +export type TransactionalEmail = { + email: string; + subject: string; + heading: string; + paragraphs: string[]; + ctaLabel?: string; + ctaUrl?: string; +}; + +/** + * The one send path. Templates below describe a message; this is what puts it + * on the wire, so the shell, the from address and the plain-text alternative + * cannot drift apart between them. + */ +export async function sendTransactionalEmail({ + email, + subject, + heading, + paragraphs, + ctaLabel, + ctaUrl, +}: TransactionalEmail) { + const bodyHtml = paragraphs + .map( + (paragraph) => + `

${escapeHtml(paragraph).replace(/\n/g, "
")}

`, + ) + .join(""); + + // Text alternative, not an afterthought: a Gmail clipping or a plain-text + // client otherwise shows a blank message, and the CTA is the whole point. + const text = [...paragraphs, ctaUrl ? `${ctaLabel ?? "Open"}: ${ctaUrl}` : ""] + .filter(Boolean) + .join("\n\n"); + + await getTransporter().sendMail({ + from: process.env.EMAIL_FROM || "noreply@datasciencegt.org", + to: email, + subject, + text, + html: renderShell({ heading, bodyHtml, ctaLabel, ctaUrl }), + }); +} + /** * One announcement to one recipient — "registration is open", "schedule is * live", "results are up". * - * `body` is plain text written by an organiser in the admin panel. It is - * escaped and then newline-split into paragraphs: treating it as HTML would - * make the compose box an injection point into thousands of inboxes. + * `body` is plain text written by an organiser in the admin panel, split on + * blank lines into paragraphs. */ export async function sendAnnouncementEmail({ email, @@ -117,87 +170,171 @@ export async function sendAnnouncementEmail({ ctaLabel?: string; ctaUrl?: string; }) { - const bodyHtml = body - .split(/\n{2,}/) - .map( - (paragraph) => - `

${escapeHtml(paragraph).replace(/\n/g, "
")}

`, - ) - .join(""); - - await getTransporter().sendMail({ - from: process.env.EMAIL_FROM || "noreply@datasciencegt.org", - to: email, + await sendTransactionalEmail({ + email, subject, - text: body, - html: renderShell({ heading, bodyHtml, ctaLabel, ctaUrl }), + heading, + paragraphs: body.split(/\n{2,}/), + ctaLabel, + ctaUrl, }); } -export async function sendAcceptanceEmail({ +/** + * Registration has opened on an edition the recipient asked to hear about. + * + * The interest list exists for exactly this moment and nothing sent it, so the + * people who asked to be told found out from somewhere else, or not at all. + */ +export async function sendRegistrationOpenEmail({ email, hackathonName, - host = "https://datasciencegt.org" + registerUrl, + host = DEFAULT_HOST, }: { email: string; hackathonName: string; + registerUrl?: string; host?: string; }) { - const mainColor = "#10b981"; - const backgroundColor = "#0f172a"; - const textColor = "#f8fafc"; + await sendTransactionalEmail({ + email, + subject: `Registration is open for ${hackathonName}`, + heading: "Registration is open", + paragraphs: [ + `You asked to hear when ${hackathonName} opened. It just did.`, + "Spots are limited and applications are reviewed as they arrive, so it is worth registering early.", + ], + ctaLabel: "Register now", + ctaUrl: registerUrl ?? `${host}/hacklytics`, + }); +} - const safeHackathonName = hackathonName - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); +/** + * A judge's application was approved. + * + * Between applying and approval a judge had no email and no status screen, + * while the success screen promised one. + */ +export async function sendJudgeApprovedEmail({ + email, + hackathonName, + host = DEFAULT_HOST, +}: { + email: string; + hackathonName: string; + host?: string; +}) { + await sendTransactionalEmail({ + email, + subject: `You're confirmed as a judge for ${hackathonName}`, + heading: "You're confirmed as a judge", + paragraphs: [ + `Your application to judge ${hackathonName} has been approved.`, + "Your judging queue is ready. On the day, scan the QR card on each table to start, score the project, and move to the next one.", + ], + ctaLabel: "Open the judge portal", + ctaUrl: `${host}/judge`, + }); +} - const safeHost = host - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); +/** + * A decision on an application to join an initiative, or on a proposal to run + * one. + * + * People applied and then heard nothing at all: the decision was recorded and + * visible only to whoever made it, so the applicant's only option was to keep + * checking the page. + */ +export async function sendInitiativeDecisionEmail({ + email, + initiativeTitle, + accepted, + kind, + note, + host = DEFAULT_HOST, +}: { + email: string; + initiativeTitle: string; + accepted: boolean; + /** "application" — joining one; "proposal" — asking to run one. */ + kind: "application" | "proposal"; + note?: string | null; + host?: string; +}) { + const subject = accepted + ? `You're in: ${initiativeTitle}` + : `An update on ${initiativeTitle}`; - const html = ` - - - - - - - - - - - -
-
-

DataScienceGT

-
-

You're Accepted!

-

- Congratulations! You have been accepted to participate in ${safeHackathonName}. -

-

- Head over to the Hackathon Hub to view the event details, find a team, and get ready to build! -

- -
- © ${new Date().getFullYear()} Data Science at Georgia Tech -
- - `; + const paragraphs = accepted + ? kind === "proposal" + ? [ + `Your proposal for ${initiativeTitle} was approved. You can now set it up and open it for applications.`, + ] + : [`You've been accepted to ${initiativeTitle}. Your leader will be in touch with what happens next.`] + : kind === "proposal" + ? [`Your proposal for ${initiativeTitle} was not taken forward this time.`] + : [ + `Your application to ${initiativeTitle} was not accepted this time.`, + "Other initiatives are open, and applying again later is welcome.", + ]; - await getTransporter().sendMail({ - from: process.env.EMAIL_FROM || "noreply@datasciencegt.org", - to: email, + if (note) paragraphs.push(note); + + await sendTransactionalEmail({ + email, + subject, + heading: accepted ? "Good news" : "An update", + paragraphs, + ctaLabel: accepted && kind === "proposal" ? "Open your initiative" : "See initiatives", + ctaUrl: + accepted && kind === "proposal" ? `${host}/lead` : `${host}/initiatives`, + }); +} + +/** Results are published and public. */ +export async function sendResultsPublishedEmail({ + email, + hackathonName, + resultsUrl, + host = DEFAULT_HOST, +}: { + email: string; + hackathonName: string; + resultsUrl?: string; + host?: string; +}) { + await sendTransactionalEmail({ + email, + subject: `${hackathonName} results are live`, + heading: "Results are live", + paragraphs: [ + `The judging for ${hackathonName} is finished and the results are published.`, + "Thank you for building with us.", + ], + ctaLabel: "See the results", + ctaUrl: resultsUrl ?? `${host}/hackathons`, + }); +} + +export async function sendAcceptanceEmail({ + email, + hackathonName, + host = DEFAULT_HOST, +}: { + email: string; + hackathonName: string; + host?: string; +}) { + await sendTransactionalEmail({ + email, subject: `You're accepted to ${hackathonName}!`, - text: `Congratulations! You have been accepted to participate in ${hackathonName}. Head over to ${host}/hackathons to view the details.`, - html, + heading: "You're accepted!", + paragraphs: [ + `Congratulations! You have been accepted to participate in ${hackathonName}.`, + "Head over to the Hackathon Hub to view the event details, find a team, and get ready to build.", + ], + ctaLabel: "Go to Hackathon Hub", + ctaUrl: `${host}/hackathons`, }); } diff --git a/packages/db/ddl/2026-08-08-notifications.sql b/packages/db/ddl/2026-08-08-notifications.sql new file mode 100644 index 00000000..8407c3f3 --- /dev/null +++ b/packages/db/ddl/2026-08-08-notifications.sql @@ -0,0 +1,62 @@ +-- W34 + W12: resumable notification sends. +-- +-- Additive only — nothing is dropped and nothing existing is rewritten, so this +-- is safe to apply before the code that uses it ships. +-- +-- Run before deploying, then confirm `pnpm --filter @query/db migrate:push` +-- reports "No changes detected." + +begin; + +-- W34: told-the-interest-list marker, per person. Mirrors +-- hackathon_participant.acceptance_email_sent_at: a send of thousands runs in +-- batches from an admin's browser and has to survive a closed tab. +alter table hackathon_interest + add column if not exists registration_open_email_sent_at timestamp; + +-- Claimed before sending, so two overlapping batches cannot both mail the same +-- person; a stale claim is reclaimable so a batch that died can be resumed. +alter table hackathon_interest + add column if not exists registration_open_email_claimed_at timestamp; + +-- A rejected address is marked rather than left pending, or a permanently bad +-- one is retried on every batch and the send never reports itself finished. +alter table hackathon_interest + add column if not exists registration_open_email_failed_at timestamp; + +-- W12: an announcement, frozen at compose time. +create table if not exists hackathon_announcement ( + id uuid primary key default gen_random_uuid(), + hackathon_id uuid not null references hackathon (id) on delete cascade, + audience text not null, + subject text not null, + heading text not null, + body text not null, + cta_label text, + cta_url text, + created_by_id text references "user" (id) on delete set null, + created_at timestamp not null default now() +); + +create index if not exists hackathon_announcement_hackathon_id_idx + on hackathon_announcement (hackathon_id); + +-- Its audience, one row per person, with the per-recipient send marker. The +-- unique constraint is what makes "exactly once" a database guarantee rather +-- than an arithmetic one in the browser. +create table if not exists hackathon_announcement_recipient ( + id uuid primary key default gen_random_uuid(), + announcement_id uuid not null + references hackathon_announcement (id) on delete cascade, + user_id text not null references "user" (id) on delete cascade, + email text not null, + claimed_at timestamp, + sent_at timestamp, + failed_at timestamp, + constraint unique_announcement_recipient unique (announcement_id, user_id) +); + +create index if not exists hackathon_announcement_recipient_pending_idx + on hackathon_announcement_recipient (announcement_id, sent_at); + +commit; diff --git a/packages/db/src/schemas/hackathons.ts b/packages/db/src/schemas/hackathons.ts index f60a2b5d..c35c3c0f 100644 --- a/packages/db/src/schemas/hackathons.ts +++ b/packages/db/src/schemas/hackathons.ts @@ -62,7 +62,14 @@ export const hackathons = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, - (table) => [index("hackathon_status_idx").on(table.status)], + (table) => [ + index("hackathon_status_idx").on(table.status), + // Every admin link builds its URL from the hackathon's name, and getById + // resolves a non-uuid argument with findFirst on this column. Two editions + // sharing a name therefore make one of them permanently unreachable + // through the admin UI, with no error to explain it. + unique("unique_hackathon_name").on(table.name), + ], ); // Teams for hackathons @@ -301,6 +308,28 @@ export const hackathonInterest = pgTable( experience: text("experience", { enum: ["first", "one_or_two", "three_plus"], }), + /** + * When this person was told registration opened. + * + * The whole reason the list exists is that one message, and a send of + * thousands runs in batches from an admin's browser — so it has to be + * resumable. Per recipient, exactly like `acceptanceEmailSentAt`: a closed + * tab, a refresh or a second click continues where it stopped instead of + * mailing everybody again. + */ + registrationOpenEmailSentAt: timestamp("registration_open_email_sent_at"), + /** Same claim mechanism as the announcement recipients above. */ + registrationOpenEmailClaimedAt: timestamp( + "registration_open_email_claimed_at", + ), + /** + * Set when the provider rejected this address, so a retry can tell a + * never-attempted recipient from a failed one — and so a permanently bad + * address cannot keep the send reporting itself unfinished forever. + */ + registrationOpenEmailFailedAt: timestamp( + "registration_open_email_failed_at", + ), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, @@ -390,9 +419,9 @@ export const hackathonEvents = pgTable( enum: ["workshop", "meal", "ceremony", "activity", "sponsor_session"], }).notNull(), location: text("location").notNull(), + points: integer("points").notNull().default(0), // For gamification startTime: timestamp("start_time").notNull(), endTime: timestamp("end_time").notNull(), - points: integer("points").notNull().default(0), // For gamification createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, @@ -467,3 +496,108 @@ export const hackathonProjectsRelations = relations( }), }), ); + +/** + * One announcement, composed once and sent in batches. + * + * The send loop runs in an organiser's browser: it walks the audience 500 at a + * time across separate requests. Without a stored copy of what was being sent + * and to whom, a closed tab left no way to resume — the only options were + * "mail everybody again" or "leave the rest unmailed", and nothing on any + * screen said which recipients had already had it. + */ +export const hackathonAnnouncements = pgTable( + "hackathon_announcement", + { + id: uuid("id").defaultRandom().primaryKey(), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + audience: text("audience", { + enum: ["interested", "registered", "approved", "checked_in"], + }).notNull(), + subject: text("subject").notNull(), + heading: text("heading").notNull(), + body: text("body").notNull(), + ctaLabel: text("cta_label"), + ctaUrl: text("cta_url"), + createdById: text("created_by_id").references(() => users.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [ + index("hackathon_announcement_hackathon_id_idx").on(table.hackathonId), + ], +); + +/** + * The audience of one announcement, frozen at compose time, one row per person. + * + * Snapshotting is what makes resuming exact: the previous implementation + * re-resolved the audience on every batch and sliced it by offset, so any row + * that moved between requests shifted everything after it — some people were + * mailed twice and others never at all, silently. + */ +export const hackathonAnnouncementRecipients = pgTable( + "hackathon_announcement_recipient", + { + id: uuid("id").defaultRandom().primaryKey(), + announcementId: uuid("announcement_id") + .notNull() + .references(() => hackathonAnnouncements.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + /** The address as it was at compose time, so a later change cannot cause a + * second delivery to the same person under a new address. */ + email: text("email").notNull(), + /** + * Claimed by a batch that is about to send to this address. + * + * Without it, two overlapping requests — two organisers, or one impatient + * double-click — both select the same `sent_at IS NULL` rows and both + * send. The claim is an atomic UPDATE, so exactly one request wins each + * row. A claim older than CLAIM_TIMEOUT is reclaimable, which is what makes + * a batch that died mid-flight resumable rather than permanently stuck. + */ + claimedAt: timestamp("claimed_at"), + sentAt: timestamp("sent_at"), + /** Set when the provider rejected this address, so a retry can tell a + * never-attempted recipient from a failed one. */ + failedAt: timestamp("failed_at"), + }, + (table) => [ + index("hackathon_announcement_recipient_pending_idx").on( + table.announcementId, + table.sentAt, + ), + // One delivery per person per announcement, enforced by the database rather + // than by the batching arithmetic that used to get it wrong. + unique("unique_announcement_recipient").on( + table.announcementId, + table.userId, + ), + ], +); + +export const hackathonAnnouncementsRelations = relations( + hackathonAnnouncements, + ({ one, many }) => ({ + hackathon: one(hackathons, { + fields: [hackathonAnnouncements.hackathonId], + references: [hackathons.id], + }), + recipients: many(hackathonAnnouncementRecipients), + }), +); + +export const hackathonAnnouncementRecipientsRelations = relations( + hackathonAnnouncementRecipients, + ({ one }) => ({ + announcement: one(hackathonAnnouncements, { + fields: [hackathonAnnouncementRecipients.announcementId], + references: [hackathonAnnouncements.id], + }), + }), +); diff --git a/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts b/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts index 7e370abb..41607194 100644 --- a/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts +++ b/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts @@ -14,6 +14,20 @@ import { } from "@query/api"; +/** + * An identifier that is safe to put in a log line. + * + * Stripe ids are `[A-Za-z0-9_]`, but the mock branch below parses the request + * body without verifying a signature, so in development these values are + * whatever the caller sent. Anything else would let a newline forge log entries + * (log injection) — and interpolating it into the message argument would let a + * `%s` be read as a format directive. Both were flagged by CodeQL. + */ +const safeLogId = (value: unknown) => + String(value ?? "") + .replace(/[^\w-]/g, "") + .slice(0, 64); + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; const stripe = process.env.STRIPE_SECRET_KEY @@ -151,8 +165,11 @@ export async function POST(req: NextRequest) { bootcampMember: session.metadata?.bootcamp === "true", }); } catch (e) { + // This id is ours (a database row), not request-derived, but it + // goes through the same path so the log format stays uniform. console.error( - `[Stripe webhook] Payment ${existingPayment.id} marked paid, membership grant failed:`, + "[Stripe webhook] payment marked paid, membership grant failed", + safeLogId(existingPayment.id), e, ); } @@ -226,8 +243,15 @@ export async function POST(req: NextRequest) { }); clearMembershipCaches(targetUser.id); } catch (e) { + // The id is passed as an argument, never interpolated into the + // message, and stripped to the characters a Stripe id can contain. + // In the mock branch the body is parsed without verifying a + // signature, so this value is not always Stripe's — a newline in it + // would forge log entries, and a `%s` would be read as a format + // directive. Flagged by CodeQL as both. console.error( - `[Stripe webhook] Payment ${session.id} recorded, membership grant failed:`, + "[Stripe webhook] membership grant failed for checkout session", + safeLogId(session.id), e, ); } @@ -334,8 +358,10 @@ export async function POST(req: NextRequest) { }); clearMembershipCaches(targetUser.id); } catch (e) { + // Same reasoning as the checkout-session branch above. console.error( - `[Stripe webhook] Payment pi_${pi.id} recorded, membership grant failed:`, + "[Stripe webhook] membership grant failed for payment intent", + safeLogId(pi.id), e, ); } diff --git a/sites/mainweb/app/(portal)/hacklytics/page.tsx b/sites/mainweb/app/(portal)/hacklytics/page.tsx index 894ad23b..29194b20 100644 --- a/sites/mainweb/app/(portal)/hacklytics/page.tsx +++ b/sites/mainweb/app/(portal)/hacklytics/page.tsx @@ -46,6 +46,17 @@ const formatRange = (start: Date, end: Date) => { return sameYear ? `${startText} – ${endText}` : `${startText} – ${endText}`; }; +/** Same fixed time zone, and the time as well — a deadline is a moment. */ +const formatDeadline = (deadline: Date) => + deadline.toLocaleString("en-US", { + month: "long", + day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", + timeZone: "America/New_York", + }); + function Field({ label, hint, @@ -166,6 +177,10 @@ export default function HacklyticsPage() { } const event = upcoming.data; + // This page is the only public entrance to the hackathon, so it has to keep + // working past the moment registration opens — before, it collected the + // interest list; after, it points at the registration itself. + const registrationOpen = event.registrationOpen; const onList = !!mine.data; const showForm = !onList || editing; const busy = join.isPending || leave.isPending; @@ -176,7 +191,9 @@ export default function HacklyticsPage() {
- Registration opens soon + {registrationOpen + ? "Registration is open" + : "Registration opens soon"}
@@ -220,7 +237,24 @@ export default function HacklyticsPage() { ) : null}
- {sessionStatus === "loading" ? ( + {registrationOpen ? ( +
+

+ Registration is open +

+

+ {event.registrationDeadline + ? `Applications close ${formatDeadline(event.registrationDeadline)}. Spots are limited.` + : "Spots are limited and applications are reviewed as they arrive."} +

+ + Register now + +
+ ) : sessionStatus === "loading" ? (

Checking sign-in…

) : !session ? (
diff --git a/sites/mainweb/app/(portal)/submit/page.tsx b/sites/mainweb/app/(portal)/submit/page.tsx index a4b88cd2..8df5da94 100644 --- a/sites/mainweb/app/(portal)/submit/page.tsx +++ b/sites/mainweb/app/(portal)/submit/page.tsx @@ -8,6 +8,22 @@ import { LiquidGlass } from "@/components/portal/LiquidGlass"; import { LoadingScreen } from "@/components/portal/LoadingScreen"; import Link from "next/link"; +/** + * Deadlines are rendered in the event's own time zone, not the viewer's. Half + * the field is remote, and a submission deadline shown in the wrong zone is the + * one rendering mistake that costs someone their entry. + */ +const formatMoment = (moment: Date) => + moment.toLocaleString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", + timeZone: "America/New_York", + }); + function SubmitPortalContent() { const { data: session, status } = useSession(); const router = useRouter(); @@ -62,6 +78,13 @@ function SubmitPortalContent() { { enabled: !!session && !!selectedHackathonId }, ); + // The window the submit mutation gates on. The page never queried it, so the + // refusal only ever arrived after the form was filled in. + const submissionWindow = trpc.team.submissionWindow.useQuery( + { hackathonId: selectedHackathonId }, + { enabled: !!session && !!selectedHackathonId }, + ); + const availableTracks = hackathonDetail?.tracks ?? []; const availableChallenges = hackathonDetail?.challenges ?? []; @@ -446,13 +469,40 @@ function SubmitPortalContent() {

Project Repository

-

+

Finalize your hackathon submission. Only the core properties are required. If you are in a team, only the{" "} Captain can deploy the final record.

+ {/* The window this form is gated on, said before it is filled + in. Without it an attendee wrote a full description and + learned it was refused only on submit. */} + {submissionWindow.data && ( +
+

+ {submissionWindow.data.cancelled + ? "This hackathon has been cancelled — nothing can be submitted." + : submissionWindow.data.notYetOpen + ? `Submission opens ${formatMoment(submissionWindow.data.opensAt)}. The form is here early so you can see what it asks for.` + : !submissionWindow.data.isOpen + ? `Submission closed ${formatMoment(submissionWindow.data.closesAt)}.` + : submissionWindow.data.canEditExisting + ? `Open until ${formatMoment(submissionWindow.data.closesAt)} · edits to an existing submission close ${formatMoment(submissionWindow.data.editsCloseAt)}.` + : `Open until ${formatMoment(submissionWindow.data.closesAt)} — but edits to an existing submission are closed, so this can only file a first entry.`} +

+
+ )} + {error && (

{error}

diff --git a/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx b/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx index 7c436ae5..03a25362 100644 --- a/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx +++ b/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx @@ -46,9 +46,18 @@ export function AnnouncementsTab({ hackathonId }: { hackathonId: string }) { hackathonId, }); - const sendAnnouncement = trpc.hackathon.sendAnnouncement.useMutation(); + const utils = trpc.useUtils(); + const { data: announcements } = trpc.hackathon.listAnnouncements.useQuery({ + hackathonId, + }); + + const createAnnouncement = trpc.hackathon.createAnnouncement.useMutation(); + const sendBatch = trpc.hackathon.sendBatch.useMutation(); const recipientCount = counts?.[audience] ?? 0; + + const unfinished = + announcements?.filter((a) => a.pending > 0) ?? []; const canSend = subject.trim().length > 0 && heading.trim().length > 0 && @@ -57,47 +66,39 @@ export function AnnouncementsTab({ hackathonId }: { hackathonId: string }) { !sending; /** - * Walks the audience in server-sized batches until it reports done. + * Walks an announcement's remaining recipients until the server reports none. * - * Sequential rather than concurrent: this is one provider account being - * asked for thousands of sends, and firing batches in parallel is how a + * Sequential rather than concurrent: this is one provider account being asked + * for thousands of sends, and firing batches in parallel is how an * announcement gets throttled into a partial delivery nobody notices. + * + * Every recipient is marked server-side as their message goes out, so closing + * the tab half-way through loses nothing — reopening offers to resume, and + * nobody is mailed twice. */ - const handleSend = async () => { - if ( - !window.confirm( - `Send "${subject}" to ${recipientCount} recipient(s)?\n\nThis cannot be unsent.`, - ) - ) - return; - + const drain = async (announcementId: string, total: number) => { setSending(true); setError(null); let sent = 0; let failed = 0; - let offset = 0; - // Bounded rather than `while (true)`: a server that stopped advancing - // nextOffset would otherwise loop forever, mailing the same batch. + // Bounded rather than `while (true)`: a server that stopped decreasing + // `remaining` would otherwise loop forever, mailing the same batch. for (let guard = 0; guard < 100; guard++) { try { - const result = await sendAnnouncement.mutateAsync({ - hackathonId, - audience, - subject: subject.trim(), - heading: heading.trim(), - body: body.trim(), - ctaLabel: ctaLabel.trim() || undefined, - ctaUrl: ctaUrl.trim() || undefined, - offset, - }); + const result = await sendBatch.mutateAsync({ announcementId }); sent += result.sent; failed += result.failed.length; - setProgress(`Sent ${sent} of ${result.totalRecipients}...`); + setProgress( + total > 0 + ? `Sent ${sent} of ${total}, ${result.remaining} to go...` + : `Sent ${sent}, ${result.remaining} to go...`, + ); - if (result.done || result.nextOffset === offset) break; - offset = result.nextOffset; + if (result.done) break; + // Nothing moved and nothing is left to try: stop rather than spin. + if (result.sent === 0 && result.failed.length === 0) break; } catch (e) { setError(e instanceof Error ? e.message : "Announcement failed"); break; @@ -108,10 +109,82 @@ export function AnnouncementsTab({ hackathonId }: { hackathonId: string }) { `Done. ${sent} delivered${failed > 0 ? `, ${failed} failed` : ""}.`, ); setSending(false); + utils.hackathon.listAnnouncements.invalidate({ hackathonId }); + }; + + const handleSend = async () => { + if ( + !window.confirm( + `Send "${subject}" to ${recipientCount} recipient(s)?\n\nThis cannot be unsent.`, + ) + ) + return; + + setSending(true); + setError(null); + + try { + const created = await createAnnouncement.mutateAsync({ + hackathonId, + audience, + subject: subject.trim(), + heading: heading.trim(), + body: body.trim(), + ctaLabel: ctaLabel.trim() || undefined, + ctaUrl: ctaUrl.trim() || undefined, + }); + + utils.hackathon.listAnnouncements.invalidate({ hackathonId }); + await drain(created.announcementId, created.totalRecipients); + } catch (e) { + setError(e instanceof Error ? e.message : "Announcement failed"); + setSending(false); + } }; return (
+ {/* A send that stopped part-way — a closed tab, a lost connection, a + timeout. The remaining recipients are recorded server-side, so this + continues rather than starting a second announcement. */} + {unfinished.length > 0 && ( + +

+ Unfinished sends +

+
+ {unfinished.map((announcement) => ( +
+
+

+ {announcement.subject} +

+

+ {announcement.sent} sent · {announcement.pending} remaining + {announcement.failed > 0 + ? ` · ${announcement.failed} rejected` + : ""} +

+
+ +
+ ))} +
+
+ )} +

@@ -279,6 +352,7 @@ export function AnnouncementsTab({ hackathonId }: { hackathonId: string }) {

)} +
); } diff --git a/sites/mainweb/components/admin/hackathons/RegistrationControls.tsx b/sites/mainweb/components/admin/hackathons/RegistrationControls.tsx index 1c2e5b88..1235ec29 100644 --- a/sites/mainweb/components/admin/hackathons/RegistrationControls.tsx +++ b/sites/mainweb/components/admin/hackathons/RegistrationControls.tsx @@ -28,6 +28,35 @@ export function RegistrationControls({ hackathonId }: { hackathonId: string }) { : null; const deadlinePassed = !!regDeadline && regDeadline < new Date(); + const registrationOpen = + hackathon?.status === "open" || hackathon?.status === "in_progress"; + + /** + * The interest list exists for one moment — this one — and nothing used to + * send it, so the people who asked to be told found out from somewhere else + * or not at all. Offered rather than automatic: the send is thousands of + * messages through a consumer Gmail account, and an organiser should choose + * when it starts. + */ + const interestStatus = trpc.hackathon.registrationOpenEmailStatus.useQuery( + { hackathonId }, + { enabled: registrationOpen }, + ); + + const [notifyError, setNotifyError] = React.useState(null); + + const notifyInterest = trpc.hackathon.notifyRegistrationOpen.useMutation({ + onSuccess: (result) => { + setNotifyError( + result.failed.length > 0 + ? `${result.failed.length} address(es) were rejected by the mail provider: ${result.failed.slice(0, 5).join(", ")}` + : null, + ); + interestStatus.refetch(); + }, + onError: (error) => setNotifyError(error.message), + }); + return (
@@ -119,6 +148,50 @@ export function RegistrationControls({ hackathonId }: { hackathonId: string }) {
+ + {registrationOpen && (interestStatus.data?.total ?? 0) > 0 && ( +
+
+

+ Interest list +

+

+ {interestStatus.data?.pending ?? 0} waiting to be told ·{" "} + {interestStatus.data?.sent ?? 0} already emailed + {(interestStatus.data?.pending ?? 0) > 500 + ? " · sends 500 at a time, press again to continue" + : ""} +

+
+ +
+ )} + + {notifyError && ( +

+ {notifyError} +

+ )} ); }