diff --git a/__tests__/createLimiter.test.ts b/__tests__/createLimiter.test.ts new file mode 100644 index 00000000..5d30e1d0 --- /dev/null +++ b/__tests__/createLimiter.test.ts @@ -0,0 +1,72 @@ +import createLimiter from '@actions/emails/createLimiter'; + +describe('createLimiter', () => { + it('runs tasks up to the concurrency limit in parallel', async () => { + const limiter = createLimiter(2); + const running: string[] = []; + const log: string[] = []; + + const task = (id: string, ms: number) => + limiter(async () => { + running.push(id); + log.push(`start:${id}(concurrent:${running.length})`); + await new Promise((r) => setTimeout(r, ms)); + running.splice(running.indexOf(id), 1); + log.push(`end:${id}`); + return id; + }); + + const results = await Promise.all([ + task('a', 50), + task('b', 50), + task('c', 10), + ]); + + expect(results).toEqual(['a', 'b', 'c']); + // a and b start concurrently (concurrent:1 then concurrent:2) + // c waits until one finishes, so it starts at concurrent:1 or concurrent:2 + // The key invariant: concurrent count never exceeds 2 + for (const entry of log) { + const match = entry.match(/concurrent:(\d+)/); + if (match) { + expect(Number(match[1])).toBeLessThanOrEqual(2); + } + } + }); + + it('returns the resolved value from the wrapped function', async () => { + const limiter = createLimiter(1); + const result = await limiter(() => Promise.resolve(42)); + expect(result).toBe(42); + }); + + it('propagates rejections', async () => { + const limiter = createLimiter(1); + await expect( + limiter(() => Promise.reject(new Error('boom'))) + ).rejects.toThrow('boom'); + }); + + it('releases the slot on rejection so subsequent tasks run', async () => { + const limiter = createLimiter(1); + await limiter(() => Promise.reject(new Error('fail'))).catch(() => {}); + const result = await limiter(() => Promise.resolve('ok')); + expect(result).toBe('ok'); + }); + + it('processes all items with concurrency 1 (serial)', async () => { + const limiter = createLimiter(1); + const order: number[] = []; + + await Promise.all( + [1, 2, 3].map((n) => + limiter(async () => { + order.push(n); + await new Promise((r) => setTimeout(r, 10)); + }) + ) + ); + + expect(order).toEqual([1, 2, 3]); + }); +}); diff --git a/__tests__/parseInviteCSV.test.ts b/__tests__/parseInviteCSV.test.ts new file mode 100644 index 00000000..dec3e9ef --- /dev/null +++ b/__tests__/parseInviteCSV.test.ts @@ -0,0 +1,185 @@ +import parseInviteCSV from '@actions/emails/parseInviteCSV'; + +describe('parseInviteCSV', () => { + it('parses valid CSV with header row', () => { + const csv = + 'First Name,Last Name,Email\n' + + 'Alice,Smith,alice@example.com\n' + + 'Bob,Jones,bob@example.com\n'; + + const result = parseInviteCSV(csv); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.body).toEqual([ + { firstName: 'Alice', lastName: 'Smith', email: 'alice@example.com' }, + { firstName: 'Bob', lastName: 'Jones', email: 'bob@example.com' }, + ]); + }); + + it('parses valid CSV without header row', () => { + const csv = 'Alice,Smith,alice@example.com\nBob,Jones,bob@example.com\n'; + + const result = parseInviteCSV(csv); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.body).toHaveLength(2); + expect(result.body[0].firstName).toBe('Alice'); + }); + + it('detects header with "email" keyword', () => { + const csv = 'name_first,name_last,email\nAlice,Smith,alice@example.com\n'; + + const result = parseInviteCSV(csv); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.body).toHaveLength(1); + }); + + it('detects header with "first" keyword', () => { + const csv = + 'First,Last,Contact\nAlice,Smith,alice@example.com\n'; + + const result = parseInviteCSV(csv); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.body).toHaveLength(1); + }); + + it('returns error for empty CSV', () => { + const result = parseInviteCSV(''); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBe('CSV file is empty.'); + }); + + it('returns error for whitespace-only CSV', () => { + const result = parseInviteCSV(' \n \n '); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBe('CSV file is empty.'); + }); + + it('returns error for header-only CSV', () => { + const csv = 'First Name,Last Name,Email\n'; + + const result = parseInviteCSV(csv); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBe('CSV has a header but no data rows.'); + }); + + it('returns error when row has fewer than 3 columns', () => { + const csv = 'First Name,Last Name,Email\nAlice,Smith\n'; + + const result = parseInviteCSV(csv); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toMatch(/expect(ed)? 3/i); + }); + + it('returns error for empty first name', () => { + const csv = 'First Name,Last Name,Email\n,Smith,alice@example.com\n'; + + const result = parseInviteCSV(csv); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain('First Name is empty'); + }); + + it('returns error for empty last name', () => { + const csv = 'First Name,Last Name,Email\nAlice,,alice@example.com\n'; + + const result = parseInviteCSV(csv); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain('Last Name is empty'); + }); + + it('returns error for invalid email', () => { + const csv = 'First Name,Last Name,Email\nAlice,Smith,not-an-email\n'; + + const result = parseInviteCSV(csv); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain('not a valid email'); + }); + + it('collects multiple row errors', () => { + const csv = + 'First Name,Last Name,Email\n' + + ',Smith,alice@example.com\n' + + 'Bob,,bob@example.com\n' + + 'Charlie,Brown,bad-email\n'; + + const result = parseInviteCSV(csv); + expect(result.ok).toBe(false); + if (result.ok) return; + const errors = result.error.split('\n'); + expect(errors).toHaveLength(3); + expect(errors[0]).toContain('Row 2'); + expect(errors[1]).toContain('Row 3'); + expect(errors[2]).toContain('Row 4'); + }); + + it('trims whitespace from values', () => { + const csv = ' Alice , Smith , alice@example.com \n'; + + const result = parseInviteCSV(csv); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.body[0]).toEqual({ + firstName: 'Alice', + lastName: 'Smith', + email: 'alice@example.com', + }); + }); + + it('skips empty lines', () => { + const csv = + 'First Name,Last Name,Email\n' + + '\n' + + 'Alice,Smith,alice@example.com\n' + + '\n' + + 'Bob,Jones,bob@example.com\n'; + + const result = parseInviteCSV(csv); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.body).toHaveLength(2); + }); + + it('handles extra columns gracefully', () => { + const csv = + 'First Name,Last Name,Email,Phone\n' + + 'Alice,Smith,alice@example.com,555-1234\n'; + + const result = parseInviteCSV(csv); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.body[0]).toEqual({ + firstName: 'Alice', + lastName: 'Smith', + email: 'alice@example.com', + }); + }); + + it('handles quoted fields with commas', () => { + const csv = + 'First Name,Last Name,Email\n' + + '"Alice, Jr.",Smith,alice@example.com\n'; + + const result = parseInviteCSV(csv); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.body[0].firstName).toBe('Alice, Jr.'); + }); + + it('row numbers are correct without header', () => { + const csv = 'Alice,Smith,alice@example.com\n,Jones,bob@example.com\n'; + + const result = parseInviteCSV(csv); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain('Row 2'); + }); +}); diff --git a/__tests__/processBulkInvites.test.ts b/__tests__/processBulkInvites.test.ts new file mode 100644 index 00000000..4653059d --- /dev/null +++ b/__tests__/processBulkInvites.test.ts @@ -0,0 +1,192 @@ +import processBulkInvites from '@actions/emails/processBulkInvites'; +import { InviteData, InviteResult } from '@typeDefs/emails'; + +// Mock parseInviteCSV so we don't need csv-parse in the test environment +jest.mock('@actions/emails/parseInviteCSV', () => ({ + __esModule: true, + default: jest.fn(), +})); + +import parseInviteCSV from '@actions/emails/parseInviteCSV'; +const mockParseCSV = parseInviteCSV as jest.MockedFunction< + typeof parseInviteCSV +>; + +const ALICE: InviteData = { + firstName: 'Alice', + lastName: 'Smith', + email: 'alice@example.com', +}; +const BOB: InviteData = { + firstName: 'Bob', + lastName: 'Jones', + email: 'bob@example.com', +}; +const CHARLIE: InviteData = { + firstName: 'Charlie', + lastName: 'Brown', + email: 'charlie@example.com', +}; + +beforeEach(() => jest.clearAllMocks()); + +describe('processBulkInvites', () => { + it('returns CSV parse error when CSV is invalid', async () => { + mockParseCSV.mockReturnValue({ ok: false, error: 'CSV file is empty.' }); + + const result = await processBulkInvites('', { + label: 'Test', + processOne: async (item) => ({ + email: item.email, + success: true, + }), + }); + + expect(result.ok).toBe(false); + expect(result.error).toBe('CSV file is empty.'); + expect(result.results).toEqual([]); + expect(result.successCount).toBe(0); + expect(result.failureCount).toBe(0); + }); + + it('processes all items and returns success', async () => { + mockParseCSV.mockReturnValue({ ok: true, body: [ALICE, BOB] }); + + const result = await processBulkInvites('csv', { + label: 'Test', + processOne: async (item) => ({ + email: item.email, + success: true, + }), + }); + + expect(result.ok).toBe(true); + expect(result.successCount).toBe(2); + expect(result.failureCount).toBe(0); + expect(result.error).toBeNull(); + expect(result.results).toHaveLength(2); + expect(result.results.map((r) => r.email)).toEqual( + expect.arrayContaining(['alice@example.com', 'bob@example.com']) + ); + }); + + it('handles processOne returning failure results', async () => { + mockParseCSV.mockReturnValue({ ok: true, body: [ALICE, BOB] }); + + const result = await processBulkInvites('csv', { + label: 'Test', + processOne: async (item) => ({ + email: item.email, + success: item.email !== 'bob@example.com', + error: + item.email === 'bob@example.com' ? 'Something went wrong' : undefined, + }), + }); + + expect(result.ok).toBe(false); + expect(result.successCount).toBe(1); + expect(result.failureCount).toBe(1); + expect(result.error).toBe('1 invite(s) failed to send.'); + }); + + it('catches exceptions thrown by processOne', async () => { + mockParseCSV.mockReturnValue({ ok: true, body: [ALICE] }); + + const result = await processBulkInvites('csv', { + label: 'Test', + processOne: async () => { + throw new Error('network timeout'); + }, + }); + + expect(result.ok).toBe(false); + expect(result.failureCount).toBe(1); + expect(result.results[0].error).toBe('network timeout'); + }); + + it('uses preprocess to filter items and include early results', async () => { + mockParseCSV.mockReturnValue({ ok: true, body: [ALICE, BOB, CHARLIE] }); + + const processOne = jest.fn(async (item: InviteData): Promise => ({ + email: item.email, + success: true, + })); + + const result = await processBulkInvites('csv', { + label: 'Test', + preprocess: async (items) => ({ + remaining: items.filter((i) => i.email !== 'bob@example.com'), + earlyResults: [ + { email: 'bob@example.com', success: false, error: 'Duplicate' }, + ], + }), + processOne, + }); + + // Bob was filtered out by preprocess + expect(processOne).toHaveBeenCalledTimes(2); + expect(processOne).not.toHaveBeenCalledWith( + expect.objectContaining({ email: 'bob@example.com' }) + ); + + // Bob's early result is included + expect(result.results).toHaveLength(3); + expect(result.successCount).toBe(2); + expect(result.failureCount).toBe(1); + expect(result.ok).toBe(false); + }); + + it('respects concurrency limit', async () => { + const items = Array.from({ length: 6 }, (_, i) => ({ + firstName: `User${i}`, + lastName: `Last${i}`, + email: `user${i}@example.com`, + })); + mockParseCSV.mockReturnValue({ ok: true, body: items }); + + let maxConcurrent = 0; + let currentConcurrent = 0; + + const result = await processBulkInvites('csv', { + label: 'Test', + concurrency: 2, + processOne: async (item) => { + currentConcurrent++; + maxConcurrent = Math.max(maxConcurrent, currentConcurrent); + await new Promise((r) => setTimeout(r, 20)); + currentConcurrent--; + return { email: item.email, success: true }; + }, + }); + + expect(maxConcurrent).toBeLessThanOrEqual(2); + expect(result.successCount).toBe(6); + }); + + it('works without concurrency limit (all items fire at once)', async () => { + const items = Array.from({ length: 5 }, (_, i) => ({ + firstName: `User${i}`, + lastName: `Last${i}`, + email: `user${i}@example.com`, + })); + mockParseCSV.mockReturnValue({ ok: true, body: items }); + + let maxConcurrent = 0; + let currentConcurrent = 0; + + const result = await processBulkInvites('csv', { + label: 'Test', + processOne: async (item) => { + currentConcurrent++; + maxConcurrent = Math.max(maxConcurrent, currentConcurrent); + await new Promise((r) => setTimeout(r, 20)); + currentConcurrent--; + return { email: item.email, success: true }; + }, + }); + + // Without concurrency limit, all 5 should run at once + expect(maxConcurrent).toBe(5); + expect(result.successCount).toBe(5); + }); +}); diff --git a/app/(api)/_actions/emails/createLimiter.ts b/app/(api)/_actions/emails/createLimiter.ts new file mode 100644 index 00000000..88215382 --- /dev/null +++ b/app/(api)/_actions/emails/createLimiter.ts @@ -0,0 +1,22 @@ +/** + * Returns an async function that enforces at most `concurrency` simultaneous + * calls. Each slot is released as soon as its fn resolves/rejects, so the + * pool is always kept as full as possible β€” no batch-boundary idle time. + */ +export default function createLimiter(concurrency: number) { + let active = 0; + const queue: (() => void)[] = []; + + return async function run(fn: () => Promise): Promise { + if (active >= concurrency) { + await new Promise((resolve) => queue.push(resolve)); + } + active++; + try { + return await fn(); + } finally { + active--; + queue.shift()?.(); + } + }; +} diff --git a/app/(api)/_actions/emails/emailTemplates/2026JudgeHubInviteTemplate.ts b/app/(api)/_actions/emails/emailTemplates/2026JudgeHubInviteTemplate.ts index db6eda9e..f43c0130 100644 --- a/app/(api)/_actions/emails/emailTemplates/2026JudgeHubInviteTemplate.ts +++ b/app/(api)/_actions/emails/emailTemplates/2026JudgeHubInviteTemplate.ts @@ -1,8 +1,10 @@ +export const JUDGE_EMAIL_SUBJECT = + '[ACTION REQUIRED] HackDavis 2026 Judging App Invite'; + export default function judgeHubInviteTemplate( fname: string, inviteLink: string ) { - const EMAIL_SUBJECT = '[ACTION REQUIRED] HackDavis 2026 Judging App Invite'; const HEADER_IMAGE_URL = `${process.env.BASE_URL}/email/2025_email_header.png`; const FOOTER_IMAGE_URL = `${process.env.BASE_URL}/email/2025_email_footer.png`; const MEETING_RECORDING_URL = @@ -18,7 +20,7 @@ export default function judgeHubInviteTemplate( - ${EMAIL_SUBJECT} + ${JUDGE_EMAIL_SUBJECT} + + +
+ HackDavis 2026 header +

Congratulations from HackDavis! πŸŽ‰

+
+

Hi ${fname},

+

We are thrilled to welcome you as a mentor at HackDavis 2026! We're excited to have your expertise help our hackers bring their ideas to life.

+

Here's what we need from you:

+
    +
  • + Claim your mentor ticket by clicking the button below: +
    • You MUST claim a ticket to attend the event.
    +
  • +
  • + Join our Discord at ${DISCORD_SERVER_URL} to stay up to date with event details. +
    • To gain access to mentor channels, please follow the instructions in #❗️read-me-first❗️.
    +
  • +
+ Claim Your Mentor Ticket +

If the button doesn't work, copy and paste this link into your browser:

+

${titoUrl}

+

After claiming your ticket, you will receive a unique QR code for check-in at the event.

+

See you at HackDavis! ✨

+

The HackDavis Team

+
+
+ HackDavis 2026 footer +
+ +`; +} diff --git a/app/(api)/_actions/emails/parseInviteCSV.ts b/app/(api)/_actions/emails/parseInviteCSV.ts index 57ba39fd..d24eb36b 100644 --- a/app/(api)/_actions/emails/parseInviteCSV.ts +++ b/app/(api)/_actions/emails/parseInviteCSV.ts @@ -1,12 +1,12 @@ import { parse } from 'csv-parse/sync'; import { z } from 'zod'; -import { JudgeInviteData } from '@typeDefs/emails'; +import { InviteData } from '@typeDefs/emails'; const emailSchema = z.string().email(); interface ParseResult { ok: true; - body: JudgeInviteData[]; + body: InviteData[]; } interface ParseError { @@ -42,7 +42,7 @@ export default function parseInviteCSV( return { ok: false, error: 'CSV has a header but no data rows.' }; } - const results: JudgeInviteData[] = []; + const results: InviteData[] = []; const errors: string[] = []; for (let i = 0; i < dataRows.length; i++) { diff --git a/app/(api)/_actions/emails/processBulkInvites.ts b/app/(api)/_actions/emails/processBulkInvites.ts new file mode 100644 index 00000000..dc4ed79b --- /dev/null +++ b/app/(api)/_actions/emails/processBulkInvites.ts @@ -0,0 +1,113 @@ +import parseInviteCSV from './parseInviteCSV'; +import createLimiter from './createLimiter'; +import { InviteData, InviteResult, BulkInviteResponse } from '@typeDefs/emails'; + +export interface BulkInviteConfig< + TData extends InviteData, + TResult extends InviteResult, +> { + label: string; + preprocess?: (items: TData[]) => Promise<{ + remaining: TData[]; + earlyResults: TResult[]; + }>; + processOne: (item: TData) => Promise; + concurrency?: number; +} + +export default async function processBulkInvites< + TData extends InviteData, + TResult extends InviteResult, +>( + csvText: string, + config: BulkInviteConfig +): Promise> { + const { label, preprocess, processOne, concurrency } = config; + + // Parse CSV + const parsed = parseInviteCSV(csvText); + if (!parsed.ok) { + return { + ok: false, + results: [], + successCount: 0, + failureCount: 0, + error: parsed.error, + }; + } + + const allItems = parsed.body as TData[]; + const totalStart = Date.now(); + + // Optional preprocess (e.g. batch duplicate check) + let remaining = allItems; + const results: TResult[] = []; + let successCount = 0; + let failureCount = 0; + + if (preprocess) { + const preprocessed = await preprocess(allItems); + remaining = preprocessed.remaining; + for (const r of preprocessed.earlyResults) { + results.push(r); + if (r.success) successCount++; + else failureCount++; + } + } + + console.log( + `[Bulk ${label} Invites] Starting ${remaining.length} of ${ + allItems.length + } β€” ${concurrency ? `concurrency: ${concurrency}` : 'internal limiters'}` + ); + + let completed = 0; + + // Process all items concurrently (with optional outer limiter) + const limiter = concurrency ? createLimiter(concurrency) : null; + + await Promise.allSettled( + remaining.map(async (item) => { + try { + const result = limiter + ? await limiter(() => processOne(item)) + : await processOne(item); + results.push(result); + if (result.success) successCount++; + else failureCount++; + } catch (e) { + const errorMsg = e instanceof Error ? e.message : 'Unknown error'; + console.error( + `[Bulk ${label} Invites] βœ— Failed: ${item.email}`, + errorMsg + ); + results.push({ + email: item.email, + success: false, + error: errorMsg, + } as TResult); + failureCount++; + } + + console.log( + `[Bulk ${label} Invites] Progress: ${++completed}/${remaining.length}` + ); + }) + ); + + const totalTime = Date.now() - totalStart; + console.log( + `[Bulk ${label} Invites] Complete: ${successCount} success, ${failureCount} failed in ${( + totalTime / 1000 + ).toFixed(1)}s` + ); + + return { + ok: failureCount === 0, + results, + successCount, + failureCount, + error: + failureCount > 0 ? `${failureCount} invite(s) failed to send.` : null, + }; +} diff --git a/app/(api)/_actions/emails/sendBulkJudgeHubInvites.ts b/app/(api)/_actions/emails/sendBulkJudgeHubInvites.ts index ac8949ba..ebb03921 100644 --- a/app/(api)/_actions/emails/sendBulkJudgeHubInvites.ts +++ b/app/(api)/_actions/emails/sendBulkJudgeHubInvites.ts @@ -1,7 +1,7 @@ 'use server'; import { GetManyUsers } from '@datalib/users/getUser'; -import parseInviteCSV from './parseInviteCSV'; +import processBulkInvites from './processBulkInvites'; import sendSingleJudgeHubInvite from './sendSingleJudgeHubInvite'; import { BulkJudgeInviteResponse, @@ -9,88 +9,48 @@ import { JudgeInviteResult, } from '@typeDefs/emails'; -const CONCURRENCY = 10; - export default async function sendBulkJudgeHubInvites( csvText: string ): Promise { - // Parse and validate CSV - const parsed = parseInviteCSV(csvText); - if (!parsed.ok) { - return { - ok: false, - results: [], - successCount: 0, - failureCount: 0, - error: parsed.error, - }; - } - - const allJudges = parsed.body; - const results: JudgeInviteResult[] = []; - let successCount = 0; - let failureCount = 0; + return processBulkInvites(csvText, { + label: 'Judge', + concurrency: 10, + + async preprocess(judges) { + const allEmails = judges.map((j) => j.email); + const existingUsers = await GetManyUsers({ email: { $in: allEmails } }); + const existingEmailSet = new Set( + existingUsers.ok + ? existingUsers.body.map((u: { email: string }) => u.email) + : [] + ); + + const remaining: JudgeInviteData[] = []; + const earlyResults: JudgeInviteResult[] = []; + + for (const judge of judges) { + if (existingEmailSet.has(judge.email)) { + earlyResults.push({ + email: judge.email, + success: false, + error: 'User already exists.', + }); + } else { + remaining.push(judge); + } + } - // Single upfront duplicate check for all emails at once - const allEmails = allJudges.map((j) => j.email); - const existingUsers = await GetManyUsers({ email: { $in: allEmails } }); - const existingEmailSet = new Set( - existingUsers.ok - ? existingUsers.body.map((u: { email: string }) => u.email) - : [] - ); + return { remaining, earlyResults }; + }, - // Partition judges into duplicates (immediate failure) and new (to send) - const judges: JudgeInviteData[] = []; - for (const judge of allJudges) { - if (existingEmailSet.has(judge.email)) { - results.push({ + async processOne(judge) { + const result = await sendSingleJudgeHubInvite(judge, true); + return { email: judge.email, - success: false, - error: 'User already exists.', - }); - failureCount++; - } else { - judges.push(judge); - } - } - - for (let i = 0; i < judges.length; i += CONCURRENCY) { - const batch: JudgeInviteData[] = judges.slice(i, i + CONCURRENCY); - - const batchResults = await Promise.allSettled( - batch.map((judge) => sendSingleJudgeHubInvite(judge, true)) - ); - - for (let j = 0; j < batchResults.length; j++) { - const result = batchResults[j]; - const email = batch[j].email; - - if (result.status === 'fulfilled' && result.value.ok) { - results.push({ - email, - success: true, - inviteUrl: result.value.inviteUrl, - }); - successCount++; - } else { - const errorMsg = - result.status === 'rejected' - ? result.reason?.message ?? 'Unknown error' - : result.value.error ?? 'Unknown error'; - console.error(`[Bulk Judge Invites] βœ— Failed: ${email}`, errorMsg); - results.push({ email, success: false, error: errorMsg }); - failureCount++; - } - } - } - - return { - ok: failureCount === 0, - results, - successCount, - failureCount, - error: - failureCount > 0 ? `${failureCount} invite(s) failed to send.` : null, - }; + success: result.ok, + inviteUrl: result.inviteUrl, + error: result.error ?? undefined, + }; + }, + }); } diff --git a/app/(api)/_actions/emails/sendBulkMentorInvites.ts b/app/(api)/_actions/emails/sendBulkMentorInvites.ts new file mode 100644 index 00000000..c568a875 --- /dev/null +++ b/app/(api)/_actions/emails/sendBulkMentorInvites.ts @@ -0,0 +1,85 @@ +'use server'; + +import getOrCreateTitoInvitation from '@actions/tito/getOrCreateTitoInvitation'; +import mentorInviteTemplate, { + MENTOR_EMAIL_SUBJECT, +} from './emailTemplates/2026MentorInviteTemplate'; +import { DEFAULT_SENDER, transporter } from './transporter'; +import createLimiter from './createLimiter'; +import processBulkInvites from './processBulkInvites'; +import { + BulkMentorInviteResponse, + MentorInviteData, + MentorInviteResult, +} from '@typeDefs/emails'; + +const TITO_CONCURRENCY = 20; +const EMAIL_CONCURRENCY = 10; + +export default async function sendBulkMentorInvites( + csvText: string, + rsvpListSlug: string, + releaseIds: string +): Promise { + // Fail fast β€” no point creating Tito invites if email can't send + if (!DEFAULT_SENDER) { + return { + ok: false, + results: [], + successCount: 0, + failureCount: 0, + error: 'Email configuration missing: SENDER_EMAIL is not set.', + }; + } + const sender = DEFAULT_SENDER; + + const titoLimiter = createLimiter(TITO_CONCURRENCY); + const emailLimiter = createLimiter(EMAIL_CONCURRENCY); + + return processBulkInvites(csvText, { + label: 'Mentor', + + async processOne(mentor) { + // Stage 1: Tito β€” slot released before email starts + const titoResult = await titoLimiter(() => + getOrCreateTitoInvitation({ + ...mentor, + rsvpListSlug, + releaseIds, + }) + ); + + if (!titoResult.ok) { + return { + email: mentor.email, + success: false, + error: titoResult.error, + }; + } + + // Stage 2: Email β€” independent limiter + try { + await emailLimiter(() => + transporter.sendMail({ + from: sender, + to: mentor.email, + subject: MENTOR_EMAIL_SUBJECT, + html: mentorInviteTemplate(mentor.firstName, titoResult.titoUrl), + }) + ); + return { + email: mentor.email, + success: true, + titoUrl: titoResult.titoUrl, + }; + } catch (e) { + const errorMsg = e instanceof Error ? e.message : 'Unknown error'; + return { + email: mentor.email, + success: false, + error: `Email send failed: ${errorMsg}`, + }; + } + }, + }); +} diff --git a/app/(api)/_actions/emails/sendSingleJudgeHubInvite.ts b/app/(api)/_actions/emails/sendSingleJudgeHubInvite.ts index 31104b7c..2d18f5b6 100644 --- a/app/(api)/_actions/emails/sendSingleJudgeHubInvite.ts +++ b/app/(api)/_actions/emails/sendSingleJudgeHubInvite.ts @@ -3,12 +3,12 @@ import GenerateInvite from '@datalib/invite/generateInvite'; import { GetManyUsers } from '@datalib/users/getUser'; import { DuplicateError, HttpError } from '@utils/response/Errors'; -import judgeHubInviteTemplate from './emailTemplates/2026JudgeHubInviteTemplate'; +import judgeHubInviteTemplate, { + JUDGE_EMAIL_SUBJECT, +} from './emailTemplates/2026JudgeHubInviteTemplate'; import { DEFAULT_SENDER, transporter } from './transporter'; import { JudgeInviteData, SingleJudgeInviteResponse } from '@typeDefs/emails'; -const EMAIL_SUBJECT = '[ACTION REQUIRED] HackDavis 2025 Judging App Invite'; - export default async function sendSingleJudgeHubInvite( options: JudgeInviteData, skipDuplicateCheck = false @@ -44,7 +44,7 @@ export default async function sendSingleJudgeHubInvite( await transporter.sendMail({ from: DEFAULT_SENDER, to: email, - subject: EMAIL_SUBJECT, + subject: JUDGE_EMAIL_SUBJECT, html: htmlContent, }); return { ok: true, inviteUrl: invite.body, error: null }; diff --git a/app/(api)/_actions/emails/sendSingleMentorInvite.ts b/app/(api)/_actions/emails/sendSingleMentorInvite.ts new file mode 100644 index 00000000..6207091c --- /dev/null +++ b/app/(api)/_actions/emails/sendSingleMentorInvite.ts @@ -0,0 +1,70 @@ +'use server'; + +import getOrCreateTitoInvitation from '@actions/tito/getOrCreateTitoInvitation'; +import mentorInviteTemplate, { + MENTOR_EMAIL_SUBJECT, +} from './emailTemplates/2026MentorInviteTemplate'; +import { DEFAULT_SENDER, transporter } from './transporter'; +import { MentorInviteData, SingleMentorInviteResponse } from '@typeDefs/emails'; + +interface MentorInviteOptions extends MentorInviteData { + rsvpListSlug: string; + releaseIds: string; +} + +export default async function sendSingleMentorInvite( + options: MentorInviteOptions +): Promise { + const totalStart = Date.now(); + const { firstName, lastName, email, rsvpListSlug, releaseIds } = options; + + try { + console.log(`[Mentor Invite] Starting invite for ${email}`); + + // Step 1: Get or create Tito invitation (with duplicate recovery) + const titoStart = Date.now(); + const titoResult = await getOrCreateTitoInvitation({ + firstName, + lastName, + email, + rsvpListSlug, + releaseIds, + }); + console.log(`[Mentor Invite] Tito: ${Date.now() - titoStart}ms`); + + if (!titoResult.ok) { + throw new Error(titoResult.error); + } + + if (!DEFAULT_SENDER) { + throw new Error('Email configuration missing: SENDER_EMAIL is not set.'); + } + + // Step 2: Send email with Tito URL + const mailStart = Date.now(); + await transporter.sendMail({ + from: DEFAULT_SENDER, + to: email, + subject: MENTOR_EMAIL_SUBJECT, + html: mentorInviteTemplate(firstName, titoResult.titoUrl), + }); + console.log(`[Mentor Invite] sendMail: ${Date.now() - mailStart}ms`); + + console.log( + `[Mentor Invite] βœ“ Done (${email}) β€” total: ${Date.now() - totalStart}ms` + ); + return { ok: true, titoUrl: titoResult.titoUrl, error: null }; + } catch (e) { + const errorMessage = + e instanceof Error + ? e.message + : typeof e === 'string' + ? e + : 'Unknown error'; + console.error( + `[Mentor Invite] βœ— Failed (${email}) after ${Date.now() - totalStart}ms:`, + errorMessage + ); + return { ok: false, error: errorMessage }; + } +} diff --git a/app/(api)/_actions/tito/createRsvpInvitation.ts b/app/(api)/_actions/tito/createRsvpInvitation.ts new file mode 100644 index 00000000..067a7871 --- /dev/null +++ b/app/(api)/_actions/tito/createRsvpInvitation.ts @@ -0,0 +1,87 @@ +'use server'; + +import { + ReleaseInvitation, + ReleaseInvitationRequest, + TitoResponse, +} from '@typeDefs/tito'; +import { TitoRequest } from './titoClient'; + +const MAX_RETRIES = 5; +const BASE_DELAY_MS = 1000; + +async function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export default async function createRsvpInvitation( + data: ReleaseInvitationRequest +): Promise> { + try { + if (!data.email?.trim()) throw new Error('Email is required'); + if (!data.rsvpListSlug) throw new Error('RSVP list slug is required'); + if (!data.releaseIds?.trim()) throw new Error('Release IDs are required'); + + const releaseIdsArray = data.releaseIds + .split(',') + .map((id) => parseInt(id.trim(), 10)) + .filter((id) => !isNaN(id)); + + if (releaseIdsArray.length === 0) { + throw new Error( + 'Invalid release IDs format. Use comma-separated numbers.' + ); + } + + const requestBody: { + email: string; + release_ids: number[]; + first_name?: string; + last_name?: string; + discount_code?: string; + } = { email: data.email.trim(), release_ids: releaseIdsArray }; + + if (data.firstName?.trim()) requestBody.first_name = data.firstName.trim(); + if (data.lastName?.trim()) requestBody.last_name = data.lastName.trim(); + if (data.discountCode?.trim()) + requestBody.discount_code = data.discountCode.trim(); + + const url = `/rsvp_lists/${data.rsvpListSlug}/release_invitations`; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + const response = await TitoRequest<{ + release_invitation: ReleaseInvitation; + }>(url, { + method: 'POST', + body: JSON.stringify({ release_invitation: requestBody }), + }); + console.log(`[Tito] βœ“ Created invitation for ${data.email}`); + return { ok: true, body: response.release_invitation, error: null }; + } catch (err: any) { + if (err.message.includes('429') && attempt < MAX_RETRIES) { + const waitMs = err.retryAfter + ? parseFloat(err.retryAfter) * BASE_DELAY_MS + : Math.pow(2, attempt) * BASE_DELAY_MS + + Math.random() * BASE_DELAY_MS; + console.warn( + `[Tito] 429 rate-limited for ${ + data.email + }, retrying in ${Math.round(waitMs)}ms (attempt ${ + attempt + 1 + }/${MAX_RETRIES})` + ); + await delay(waitMs); + continue; + } + throw err; + } + } + + throw new Error('Tito API rate limit exceeded after 5 retries'); + } catch (e) { + const error = e instanceof Error ? e.message : 'Unknown error'; + console.error('[Tito] createRsvpInvitation failed:', error); + return { ok: false, body: null, error }; + } +} diff --git a/app/(api)/_actions/tito/deleteRsvpInvitationByEmail.ts b/app/(api)/_actions/tito/deleteRsvpInvitationByEmail.ts new file mode 100644 index 00000000..1f0909b8 --- /dev/null +++ b/app/(api)/_actions/tito/deleteRsvpInvitationByEmail.ts @@ -0,0 +1,59 @@ +'use server'; + +import { ReleaseInvitation } from '@typeDefs/tito'; +import { TitoRequest } from './titoClient'; + +export default async function deleteRsvpInvitationByEmail( + rsvpListSlug: string, + email: string +): Promise<{ ok: boolean; error: string | null }> { + try { + const normalizedEmail = email.trim().toLowerCase(); + if (!normalizedEmail) throw new Error('Email is required'); + if (!rsvpListSlug?.trim()) throw new Error('RSVP list slug is required'); + + const pageSize = 1000; + let page = 1; + let foundSlug: string | null = null; + + while (!foundSlug) { + const url = `/rsvp_lists/${rsvpListSlug}/release_invitations?page[size]=${pageSize}&page[number]=${page}`; + const data = await TitoRequest<{ + release_invitations: ReleaseInvitation[]; + }>(url); + const invitations = data.release_invitations ?? []; + + const match = invitations.find( + (inv) => inv.email?.toLowerCase() === normalizedEmail + ); + if (match?.slug) { + foundSlug = match.slug; + break; + } + + if (invitations.length < pageSize) break; + page++; + } + + if (!foundSlug) { + return { + ok: false, + error: 'No existing invitation found for this email', + }; + } + + await TitoRequest( + `/rsvp_lists/${rsvpListSlug}/release_invitations/${foundSlug}`, + { + method: 'DELETE', + } + ); + + console.log(`[Tito] Deleted invitation for ${email}`); + return { ok: true, error: null }; + } catch (e) { + const error = e instanceof Error ? e.message : 'Unknown error'; + console.error('[Tito] deleteRsvpInvitationByEmail failed:', error); + return { ok: false, error }; + } +} diff --git a/app/(api)/_actions/tito/getOrCreateTitoInvitation.ts b/app/(api)/_actions/tito/getOrCreateTitoInvitation.ts new file mode 100644 index 00000000..6f240954 --- /dev/null +++ b/app/(api)/_actions/tito/getOrCreateTitoInvitation.ts @@ -0,0 +1,71 @@ +'use server'; + +import createRsvpInvitation from './createRsvpInvitation'; +import getRsvpInvitationByEmail from './getRsvpInvitationByEmail'; +import deleteRsvpInvitationByEmail from './deleteRsvpInvitationByEmail'; +import { ReleaseInvitationRequest } from '@typeDefs/tito'; + +function isDuplicateTicketError(error: string | null | undefined): boolean { + if (!error) return false; + const normalized = error.toLowerCase(); + return ( + normalized.includes('already has a tito ticket attached') || + normalized.includes('already has a ticket attached') || + normalized.includes('email has already been taken') || + normalized.includes('has already been taken') || + (normalized.includes('"email"') && normalized.includes('already taken')) || + normalized.includes('already exists') || + (normalized.includes('already') && normalized.includes('invitation')) + ); +} + +export default async function getOrCreateTitoInvitation( + data: ReleaseInvitationRequest +): Promise<{ ok: true; titoUrl: string } | { ok: false; error: string }> { + const { email, rsvpListSlug } = data; + + let titoResponse = await createRsvpInvitation(data); + + // Duplicate recovery: reuse existing URL if possible, otherwise delete + recreate + if (!titoResponse.ok && isDuplicateTicketError(titoResponse.error)) { + console.warn(`[Tito] Duplicate detected for ${email}, attempting recovery`); + + const existingRes = await getRsvpInvitationByEmail(rsvpListSlug, email); + if (existingRes.ok && existingRes.body) { + const existingUrl = existingRes.body.unique_url ?? existingRes.body.url; + if (existingUrl) { + console.log(`[Tito] Reusing existing URL for ${email}`); + return { ok: true, titoUrl: existingUrl }; + } + } + + console.warn( + `[Tito] No usable URL found, deleting and recreating for ${email}` + ); + const deleteRes = await deleteRsvpInvitationByEmail(rsvpListSlug, email); + if (!deleteRes.ok) { + return { + ok: false, + error: `Duplicate recovery failed (delete): ${deleteRes.error}`, + }; + } + titoResponse = await createRsvpInvitation(data); + } + + if (!titoResponse.ok || !titoResponse.body) { + return { + ok: false, + error: titoResponse.error ?? 'Failed to create Tito invitation', + }; + } + + const titoUrl = titoResponse.body.unique_url ?? titoResponse.body.url; + if (!titoUrl) { + return { + ok: false, + error: 'Tito invitation created but no URL was returned', + }; + } + + return { ok: true, titoUrl }; +} diff --git a/app/(api)/_actions/tito/getReleases.ts b/app/(api)/_actions/tito/getReleases.ts new file mode 100644 index 00000000..e3c66b8b --- /dev/null +++ b/app/(api)/_actions/tito/getReleases.ts @@ -0,0 +1,20 @@ +'use server'; + +import { Release, TitoResponse } from '@typeDefs/tito'; +import { TitoRequest } from './titoClient'; + +export default async function getReleases(): Promise> { + try { + const start = Date.now(); + const data = await TitoRequest<{ releases: Release[] }>('/releases'); + console.log(`[Tito] getReleases: ${Date.now() - start}ms`); + + const releases = data.releases ?? []; + console.log(`[Tito] Fetched ${releases.length} releases`); + return { ok: true, body: releases, error: null }; + } catch (e) { + const error = e instanceof Error ? e.message : 'Unknown error'; + console.error('[Tito] getReleases failed:', error); + return { ok: false, body: null, error }; + } +} diff --git a/app/(api)/_actions/tito/getRsvpInvitationByEmail.ts b/app/(api)/_actions/tito/getRsvpInvitationByEmail.ts new file mode 100644 index 00000000..1a5788c9 --- /dev/null +++ b/app/(api)/_actions/tito/getRsvpInvitationByEmail.ts @@ -0,0 +1,48 @@ +'use server'; + +import { ReleaseInvitation, TitoResponse } from '@typeDefs/tito'; +import { TitoRequest } from './titoClient'; + +export default async function getRsvpInvitationByEmail( + rsvpListSlug: string, + email: string +): Promise> { + try { + const normalizedEmail = email.trim().toLowerCase(); + if (!normalizedEmail) throw new Error('Email is required'); + if (!rsvpListSlug?.trim()) throw new Error('RSVP list slug is required'); + + const pageSize = 1000; + let page = 1; + let hasMorePages = true; + + while (hasMorePages) { + const url = `/rsvp_lists/${rsvpListSlug}/release_invitations?page[size]=${pageSize}&page[number]=${page}`; + const data = await TitoRequest<{ + release_invitations: ReleaseInvitation[]; + }>(url); + const invitations = data.release_invitations ?? []; + + const match = invitations.find( + (inv) => inv.email?.toLowerCase() === normalizedEmail + ); + if (match) return { ok: true, body: match, error: null }; + + if (invitations.length < pageSize) { + hasMorePages = false; + } else { + page++; + } + } + + return { + ok: false, + body: null, + error: 'No existing invitation found for this email', + }; + } catch (e) { + const error = e instanceof Error ? e.message : 'Unknown error'; + console.error('[Tito] getRsvpInvitationByEmail failed:', error); + return { ok: false, body: null, error }; + } +} diff --git a/app/(api)/_actions/tito/getRsvpLists.ts b/app/(api)/_actions/tito/getRsvpLists.ts new file mode 100644 index 00000000..027ebcc8 --- /dev/null +++ b/app/(api)/_actions/tito/getRsvpLists.ts @@ -0,0 +1,22 @@ +'use server'; + +import { RsvpList, TitoResponse } from '@typeDefs/tito'; +import { TitoRequest } from './titoClient'; + +export default async function getRsvpLists(): Promise< + TitoResponse +> { + try { + const start = Date.now(); + const data = await TitoRequest<{ rsvp_lists: RsvpList[] }>('/rsvp_lists'); + console.log(`[Tito] getRsvpLists: ${Date.now() - start}ms`); + + const rsvpLists = data.rsvp_lists ?? []; + console.log(`[Tito] Fetched ${rsvpLists.length} RSVP lists`); + return { ok: true, body: rsvpLists, error: null }; + } catch (e) { + const error = e instanceof Error ? e.message : 'Unknown error'; + console.error('[Tito] getRsvpLists failed:', error); + return { ok: false, body: null, error }; + } +} diff --git a/app/(api)/_actions/tito/titoClient.ts b/app/(api)/_actions/tito/titoClient.ts new file mode 100644 index 00000000..df5a48d4 --- /dev/null +++ b/app/(api)/_actions/tito/titoClient.ts @@ -0,0 +1,38 @@ +const TITO_API_TOKEN = process.env.TITO_API_TOKEN; +const TITO_ACCOUNT_SLUG = process.env.TITO_ACCOUNT_SLUG; +const TITO_EVENT_SLUG = process.env.TITO_EVENT_SLUG; + +export async function TitoRequest( + endpoint: string, + options: RequestInit = {} +): Promise { + if (!TITO_API_TOKEN || !TITO_ACCOUNT_SLUG || !TITO_EVENT_SLUG) { + throw new Error('Missing Tito API configuration in environment variables'); + } + + const baseUrl = `https://api.tito.io/v3/${TITO_ACCOUNT_SLUG}/${TITO_EVENT_SLUG}`; + const url = `${baseUrl}${endpoint}`; + + const response = await fetch(url, { + ...options, + headers: { + Authorization: `Token token=${TITO_API_TOKEN}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + const retryAfter = response.headers.get('Retry-After'); + const error = new Error(`Tito API ${response.status}: ${errorText}`); + if (retryAfter) (error as any).retryAfter = retryAfter; + throw error; + } + + // DELETE responses may return 204 No Content + if (response.status === 204) return {} as T; + + return response.json(); +} diff --git a/app/(pages)/admin/_components/MentorInvites/MentorBulkInviteForm.tsx b/app/(pages)/admin/_components/MentorInvites/MentorBulkInviteForm.tsx new file mode 100644 index 00000000..a082489b --- /dev/null +++ b/app/(pages)/admin/_components/MentorInvites/MentorBulkInviteForm.tsx @@ -0,0 +1,372 @@ +'use client'; + +import { ChangeEvent, useState } from 'react'; +import sendBulkMentorInvites from '@actions/emails/sendBulkMentorInvites'; +import { BulkMentorInviteResponse, MentorInviteData } from '@typeDefs/emails'; +import { Release, RsvpList } from '@typeDefs/tito'; +import { generateInviteResultsCSV } from '../../_utils/generateInviteResultsCSV'; + +/** + * Browser-safe CSV preview parser (no Node.js deps). Full validation runs server-side. + * Note: uses simple comma-split β€” quoted fields containing commas are not supported. + */ +function previewCSV( + text: string +): { ok: true; rows: MentorInviteData[] } | { ok: false; error: string } { + const lines = text + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + if (lines.length === 0) return { ok: false, error: 'CSV is empty.' }; + + const firstCells = lines[0].toLowerCase(); + const hasHeader = + firstCells.includes('first') || firstCells.includes('email'); + const dataLines = hasHeader ? lines.slice(1) : lines; + if (dataLines.length === 0) + return { ok: false, error: 'No data rows found.' }; + + const rows: MentorInviteData[] = []; + for (let i = 0; i < dataLines.length; i++) { + const cols = dataLines[i].split(',').map((c) => c.trim()); + if (cols.length < 3) { + return { + ok: false, + error: `Row ${hasHeader ? i + 2 : i + 1}: expected 3 columns, got ${ + cols.length + }.`, + }; + } + rows.push({ firstName: cols[0], lastName: cols[1], email: cols[2] }); + } + return { ok: true, rows }; +} + +type Status = 'idle' | 'previewing' | 'sending' | 'done'; + +interface Props { + rsvpLists: RsvpList[]; + releases: Release[]; +} + +export default function MentorBulkInviteForm({ rsvpLists, releases }: Props) { + const [status, setStatus] = useState('idle'); + const [csvText, setCsvText] = useState(''); + const [preview, setPreview] = useState([]); + const [parseError, setParseError] = useState(''); + const [result, setResult] = useState(null); + const [selectedListSlug, setSelectedListSlug] = useState( + rsvpLists[0]?.slug ?? '' + ); + const [selectedReleases, setSelectedReleases] = useState([]); + const [configError, setConfigError] = useState(''); + + const toggleRelease = (id: string) => + setSelectedReleases((prev) => + prev.includes(id) ? prev.filter((r) => r !== id) : [...prev, id] + ); + + const handleFileChange = (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (ev) => { + const text = ev.target?.result as string; + setCsvText(text); + const parsed = previewCSV(text); + if (parsed.ok) { + setPreview(parsed.rows); + setParseError(''); + setStatus('previewing'); + } else { + setParseError(parsed.error); + setPreview([]); + setStatus('idle'); + } + }; + reader.readAsText(file); + }; + + const handleSend = async () => { + if (!selectedListSlug) { + setConfigError('Please select an RSVP list.'); + return; + } + if (selectedReleases.length === 0) { + setConfigError('Please select at least one release.'); + return; + } + setConfigError(''); + setStatus('sending'); + setResult(null); + + const response = await sendBulkMentorInvites( + csvText, + selectedListSlug, + selectedReleases.join(',') + ); + setResult(response); + setStatus('done'); + }; + + const handleDownloadCSV = () => { + if (!result) return; + const resultMap = new Map( + result.results.map((r) => [r.email.toLowerCase(), r]) + ); + const rows = preview.map((mentor) => { + const res = resultMap.get(mentor.email.toLowerCase()); + return { + firstName: mentor.firstName, + lastName: mentor.lastName, + email: mentor.email, + titoUrl: res?.titoUrl, + success: res?.success ?? false, + error: res?.error, + }; + }); + const csv = generateInviteResultsCSV(rows); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `mentor-invites-${ + new Date().toISOString().split('T')[0] + }.csv`; + link.click(); + URL.revokeObjectURL(url); + }; + + const handleReset = () => { + setStatus('idle'); + setCsvText(''); + setPreview([]); + setParseError(''); + setResult(null); + setConfigError(''); + setSelectedReleases([]); + }; + + return ( +
+ {/* File input */} +
+ + +
+ + {/* Parse error */} + {parseError && ( +
+

CSV errors:

+
+            {parseError}
+          
+
+ )} + + {/* Preview table */} + {status === 'previewing' && preview.length > 0 && ( +
+

+ {preview.length} mentor + {preview.length !== 1 ? 's' : ''} found. Configure Tito settings and + review before sending: +

+ +
+
+ + + + + + + + + + {preview.map((mentor, i) => ( + + + + + + ))} + +
+ First Name + + Last Name + + Email +
+ {mentor.firstName} + + {mentor.lastName} + + {mentor.email} +
+
+
+ + {/* RSVP List */} +
+ + +
+ + {/* Releases */} +
+
+ + +
+
+ {releases.map((release) => ( + + ))} +
+
+ + {configError && ( +

+ {configError} +

+ )} + + +
+ )} + + {/* Sending spinner */} + {status === 'sending' && ( +
+
+ Sending invites… +
+ )} + + {/* Results */} + {status === 'done' && result && ( +
+
+
+

+ {result.successCount} +

+

Sent

+
+
+

+ {result.failureCount} +

+

Failed

+
+
+ + {result.failureCount > 0 && ( +
+

+ Failed invites +

+
+ {result.results + .filter((r) => !r.success) + .map((r, i) => ( +
+ + {r.email} + + {r.error} +
+ ))} +
+
+ )} + +
+ + +
+
+ )} +
+ ); +} diff --git a/app/(pages)/admin/_components/MentorInvites/MentorInvitesPanel.tsx b/app/(pages)/admin/_components/MentorInvites/MentorInvitesPanel.tsx new file mode 100644 index 00000000..537d62ed --- /dev/null +++ b/app/(pages)/admin/_components/MentorInvites/MentorInvitesPanel.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import getRsvpLists from '@actions/tito/getRsvpLists'; +import getReleases from '@actions/tito/getReleases'; +import { Release, RsvpList } from '@typeDefs/tito'; +import MentorSingleInviteForm from './MentorSingleInviteForm'; +import MentorBulkInviteForm from './MentorBulkInviteForm'; + +type Mode = 'single' | 'bulk'; + +export default function MentorInvitesPanel() { + const [mode, setMode] = useState('single'); + const [rsvpLists, setRsvpLists] = useState([]); + const [releases, setReleases] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(''); + + useEffect(() => { + (async () => { + const [rsvpRes, relRes] = await Promise.all([ + getRsvpLists(), + getReleases(), + ]); + if (!rsvpRes.ok || !rsvpRes.body) { + setLoadError(rsvpRes.error ?? 'Failed to load RSVP lists.'); + } else if (!relRes.ok || !relRes.body) { + setLoadError(relRes.error ?? 'Failed to load releases.'); + } else { + setRsvpLists(rsvpRes.body); + setReleases(relRes.body); + } + setLoading(false); + })(); + }, []); + + if (loading) { + return ( +
+
+ Loading Tito configuration… +
+ ); + } + + if (loadError) { + return ( +

+ {loadError} +

+ ); + } + + return ( +
+ {/* Single / Bulk toggle */} +
+ {(['single', 'bulk'] as Mode[]).map((m) => ( + + ))} +
+ + {mode === 'single' ? ( +
+

+ Send a Tito invite to a single mentor by entering their details + below. +

+ +
+ ) : ( +
+

+ Upload a CSV with columns{' '} + + First Name, Last Name, Email + {' '} + to send Tito invites to multiple mentors at once. +

+ +
+ )} +
+ ); +} diff --git a/app/(pages)/admin/_components/MentorInvites/MentorSingleInviteForm.tsx b/app/(pages)/admin/_components/MentorInvites/MentorSingleInviteForm.tsx new file mode 100644 index 00000000..a6aae805 --- /dev/null +++ b/app/(pages)/admin/_components/MentorInvites/MentorSingleInviteForm.tsx @@ -0,0 +1,176 @@ +'use client'; + +import { FormEvent, useState } from 'react'; +import sendSingleMentorInvite from '@actions/emails/sendSingleMentorInvite'; +import { Release, RsvpList } from '@typeDefs/tito'; + +interface Props { + rsvpLists: RsvpList[]; + releases: Release[]; +} + +export default function MentorSingleInviteForm({ rsvpLists, releases }: Props) { + const [loading, setLoading] = useState(false); + const [titoUrl, setTitoUrl] = useState(''); + const [error, setError] = useState(''); + const [selectedListSlug, setSelectedListSlug] = useState( + rsvpLists[0]?.slug ?? '' + ); + const [selectedReleases, setSelectedReleases] = useState([]); + + const toggleRelease = (id: string) => + setSelectedReleases((prev) => + prev.includes(id) ? prev.filter((r) => r !== id) : [...prev, id] + ); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (!selectedListSlug) { + setError('Please select an RSVP list.'); + return; + } + if (selectedReleases.length === 0) { + setError('Please select at least one release.'); + return; + } + + setLoading(true); + setTitoUrl(''); + setError(''); + + const formData = new FormData(e.currentTarget); + const result = await sendSingleMentorInvite({ + firstName: formData.get('firstName') as string, + lastName: formData.get('lastName') as string, + email: formData.get('email') as string, + rsvpListSlug: selectedListSlug, + releaseIds: selectedReleases.join(','), + }); + + setLoading(false); + + if (result.ok) { + setTitoUrl(result.titoUrl ?? ''); + (e.target as HTMLFormElement).reset(); + setSelectedReleases([]); + } else { + setError(result.error ?? 'An unexpected error occurred.'); + } + }; + + return ( +
+ {/* Name + Email */} +
+
+ + +
+
+ + +
+
+
+ + +
+ + {/* RSVP List */} +
+ + +
+ + {/* Releases */} +
+
+ + +
+
+ {releases.map((release) => ( + + ))} +
+
+ + + + {error && ( +

+ {error} +

+ )} + {titoUrl && ( +
+

Invite sent!

+

{titoUrl}

+
+ )} +
+ ); +} diff --git a/app/(pages)/admin/_utils/generateInviteResultsCSV.ts b/app/(pages)/admin/_utils/generateInviteResultsCSV.ts new file mode 100644 index 00000000..401ec3a7 --- /dev/null +++ b/app/(pages)/admin/_utils/generateInviteResultsCSV.ts @@ -0,0 +1,48 @@ +export interface InviteResultRow { + firstName: string; + lastName: string; + email: string; + titoUrl?: string; + hubUrl?: string; // populated for hacker invites; omitted for mentor-only + success: boolean; + error?: string; +} + +function escapeCell(value: string): string { + return `"${value.replace(/"/g, '""')}"`; +} + +/** + * Generates a CSV string from bulk invite results. + * @param rows Merged invite result rows (one per person). + * @param includeHub Set true for hacker invites that include a Hub URL column. + */ +export function generateInviteResultsCSV( + rows: InviteResultRow[], + includeHub = false +): string { + const headers = [ + 'Email', + 'First Name', + 'Last Name', + 'Tito Invite URL', + ...(includeHub ? ['Hub Invite URL'] : []), + 'Success', + 'Notes', + ]; + + const csvRows = rows.map((row) => { + const cells = [ + row.email, + row.firstName, + row.lastName, + row.titoUrl ?? '', + ...(includeHub ? [row.hubUrl ?? ''] : []), + row.success ? 'TRUE' : 'FALSE', + row.success ? '' : row.error ?? 'Unknown error', + ]; + return cells.map(escapeCell).join(','); + }); + + return [headers.join(','), ...csvRows].join('\n'); +} diff --git a/app/(pages)/admin/invite-judges/page.tsx b/app/(pages)/admin/invite-judges/page.tsx deleted file mode 100644 index c892ef0b..00000000 --- a/app/(pages)/admin/invite-judges/page.tsx +++ /dev/null @@ -1,35 +0,0 @@ -'use client'; - -import JudgeSingleInviteForm from '../_components/JudgeInvites/JudgeSingleInviteForm'; -import JudgeBulkInviteForm from '../_components/JudgeInvites/JudgeBulkInviteForm'; - -export default function InviteJudgesPage() { - return ( -
-

Invite Judges

- -
-

Invite a Judge

-

- Send a HackDavis Hub invite to a single judge by entering their - details below. -

- -
- -
- -
-

Bulk Invite Judges

-

- Upload a CSV with columns{' '} - - First Name, Last Name, Email - {' '} - to send Hub invites to multiple judges at once. -

- -
-
- ); -} diff --git a/app/(pages)/admin/invites/page.tsx b/app/(pages)/admin/invites/page.tsx new file mode 100644 index 00000000..7e1aed0f --- /dev/null +++ b/app/(pages)/admin/invites/page.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useState } from 'react'; +import JudgeSingleInviteForm from '../_components/JudgeInvites/JudgeSingleInviteForm'; +import JudgeBulkInviteForm from '../_components/JudgeInvites/JudgeBulkInviteForm'; +import MentorInvitesPanel from '../_components/MentorInvites/MentorInvitesPanel'; + +type Tab = 'judges' | 'mentors'; + +export default function InvitesPage() { + const [tab, setTab] = useState('judges'); + + return ( +
+

Invites

+ + {/* Tab bar */} +
+ {(['judges', 'mentors'] as Tab[]).map((t) => ( + + ))} +
+ + {/* Judges panel */} + {tab === 'judges' && ( +
+
+

Invite a Judge

+

+ Send a HackDavis Hub invite to a single judge by entering their + details below. +

+ +
+ +
+ +
+

Bulk Invite Judges

+

+ Upload a CSV with columns{' '} + + First Name, Last Name, Email + {' '} + to send Hub invites to multiple judges at once. +

+ +
+
+ )} + + {/* Mentors panel */} + {tab === 'mentors' && ( +
+

Mentor Invites

+ +
+ )} +
+ ); +} diff --git a/app/(pages)/admin/page.tsx b/app/(pages)/admin/page.tsx index cd139d2f..cb69ab44 100644 --- a/app/(pages)/admin/page.tsx +++ b/app/(pages)/admin/page.tsx @@ -22,8 +22,8 @@ const action_links = [ body: 'Create Panels', }, { - href: '/admin/invite-judges', - body: 'Invite Judges', + href: '/admin/invites', + body: 'Invites', }, { href: '/admin/invite-hackers', diff --git a/app/_types/emails.ts b/app/_types/emails.ts index 9d2c2991..6b518997 100644 --- a/app/_types/emails.ts +++ b/app/_types/emails.ts @@ -1,27 +1,52 @@ // Judge Hub invite types -export interface JudgeInviteData { +export interface InviteData { firstName: string; lastName: string; email: string; } -export interface JudgeInviteResult { +export interface InviteResult { email: string; success: boolean; - inviteUrl?: string; error?: string; } -export interface BulkJudgeInviteResponse { +export interface BulkInviteResponse { ok: boolean; - results: JudgeInviteResult[]; + results: R[]; successCount: number; failureCount: number; error: string | null; } +// ── Judge types ───────────────────────────────────────────────────────────── + +export interface JudgeInviteData extends InviteData {} + +export interface JudgeInviteResult extends InviteResult { + inviteUrl?: string; +} + +export type BulkJudgeInviteResponse = BulkInviteResponse; + export interface SingleJudgeInviteResponse { ok: boolean; inviteUrl?: string; error: string | null; } + +// Mentor Hub invite types + +export interface MentorInviteData extends InviteData {} + +export interface MentorInviteResult extends InviteResult { + titoUrl?: string; +} + +export type BulkMentorInviteResponse = BulkInviteResponse; + +export interface SingleMentorInviteResponse { + ok: boolean; + titoUrl?: string; + error: string | null; +} diff --git a/app/_types/tito.ts b/app/_types/tito.ts new file mode 100644 index 00000000..a1bc9920 --- /dev/null +++ b/app/_types/tito.ts @@ -0,0 +1,41 @@ +export interface RsvpList { + id: string; + slug: string; + title: string; + release_ids?: number[]; + question_ids?: number[]; + activity_ids?: number[]; +} + +export interface Release { + id: string; + slug: string; + title: string; + quantity?: number; +} + +export interface ReleaseInvitation { + id: string; + slug: string; + email: string; + first_name: string; + last_name: string; + url?: string; + unique_url?: string; + created_at: string; +} + +export interface ReleaseInvitationRequest { + firstName: string; + lastName: string; + email: string; + rsvpListSlug: string; + releaseIds: string; // comma-separated release IDs + discountCode?: string; +} + +export interface TitoResponse { + ok: boolean; + body: T | null; + error: string | null; +}