From ad971ade5148f6fb1d7fc3d49e15d32ba16d306a Mon Sep 17 00:00:00 2001 From: reehals Date: Fri, 1 May 2026 03:08:37 -0700 Subject: [PATCH 01/12] #515: Add support for hacker emails in server actions --- .../2026HackerInviteTemplate.ts | 86 +++++++++++ .../_actions/emails/sendBulkHackerInvites.ts | 135 ++++++++++++++++++ .../_actions/emails/sendSingleHackerInvite.ts | 79 ++++++++++ app/_types/emails.ts | 18 +++ 4 files changed, 318 insertions(+) create mode 100644 app/(api)/_actions/emails/emailTemplates/2026HackerInviteTemplate.ts create mode 100644 app/(api)/_actions/emails/sendBulkHackerInvites.ts create mode 100644 app/(api)/_actions/emails/sendSingleHackerInvite.ts diff --git a/app/(api)/_actions/emails/emailTemplates/2026HackerInviteTemplate.ts b/app/(api)/_actions/emails/emailTemplates/2026HackerInviteTemplate.ts new file mode 100644 index 00000000..663dc710 --- /dev/null +++ b/app/(api)/_actions/emails/emailTemplates/2026HackerInviteTemplate.ts @@ -0,0 +1,86 @@ +export const HACKER_EMAIL_SUBJECT = + '[ACTION REQUIRED] HackDavis 2026 Hacker Invite'; + +export default function hackerInviteTemplate( + fname: string, + titoUrl: string, + hubInviteUrl: string +) { + const HEADER_IMAGE_URL = `${process.env.BASE_URL}/email/2026_header.png`; + const FOOTER_IMAGE_URL = `${process.env.BASE_URL}/email/2026_footer.png`; + const DISCORD_SERVER_URL = 'https://discord.gg/wc6QQEc'; + const CLAIM_TITO_TICKET_DEADLINE = '11:59pm on May 4th'; + const DOE_DATE = 'May 9th, 2026'; + const DOE = 'May 9 - 10, 2026'; + const DOE_LOCATION = 'University Credit Union Center, UC Davis'; + + return ` + + + + + ${HACKER_EMAIL_SUBJECT} + + + +
+ HackDavis 2026 header +

+ Welcome to + HackDavis 2026! +

+

✦ ${DOE} ✦ ${DOE_LOCATION}

+
+
+

Hi ${fname},

+
+

You're officially invited to HackDavis 2026 — California's largest social good hackathon! We're so excited to have you join us for a weekend of building, learning, and making an impact.

+

IMPORTANT NEXT STEPS:

+
+

1️⃣ Claim your E-Ticket by ${CLAIM_TITO_TICKET_DEADLINE}

+

👉 Tito Ticket: ${titoUrl}

+

Please use this unique invite link to claim your e-ticket. Do NOT share it with anyone else. You will be asked to show your e-ticket at the check-in table to enter the venue.

+
+
+

2️⃣ Register on HackDavis Hub by ${DOE_DATE}

+

👉 Hub Invite: ${hubInviteUrl}

+

HackDavis Hub is where you'll get prize and track information, view the schedule, and track judging. Use this unique link to create your account. Do NOT share it with anyone else.

+
+
+

3️⃣ Join our Discord server

+

👉 Discord Server: ${DISCORD_SERVER_URL}

+

We'll be using Discord as our main space for announcements, updates, and support during the event. You can use it to:

+

🔹 Get quick answers from the HackDavis team

+

🔹 Stay in the loop on event updates

+

🔹 Connect with other hackers, mentors & sponsors

+
+

Please feel free to reach out if you have any questions or concerns. We can't wait to see what you build!

+
+

Thank you,
The HackDavis Team

+
+
+ HackDavis 2026 footer +
+ +`; +} diff --git a/app/(api)/_actions/emails/sendBulkHackerInvites.ts b/app/(api)/_actions/emails/sendBulkHackerInvites.ts new file mode 100644 index 00000000..cae29c95 --- /dev/null +++ b/app/(api)/_actions/emails/sendBulkHackerInvites.ts @@ -0,0 +1,135 @@ +'use server'; + +import getOrCreateTitoInvitation from '@actions/tito/getOrCreateTitoInvitation'; +import getAllRsvpInvitations from '@actions/tito/getAllRsvpInvitations'; +import GenerateInvite from '@datalib/invite/generateInvite'; +import { GetManyUsers } from '@datalib/users/getUser'; +import hackerInviteTemplate, { + HACKER_EMAIL_SUBJECT, +} from './emailTemplates/2026HackerInviteTemplate'; +import { DEFAULT_SENDER, transporter } from './transporter'; +import createLimiter from './createLimiter'; +import processBulkInvites from './processBulkInvites'; +import { + BulkHackerInviteResponse, + HackerInviteData, + HackerInviteResult, +} from '@typeDefs/emails'; + +const TITO_CONCURRENCY = 20; +const EMAIL_CONCURRENCY = 10; + +export default async function sendBulkHackerInvites( + csvText: string, + rsvpListSlug: string, + releaseIds: string +): Promise { + if (!DEFAULT_SENDER) { + return { + ok: false, + results: [], + successCount: 0, + failureCount: 0, + error: 'Email configuration missing: SENDER_EMAIL is not set.', + }; + } + const sender = DEFAULT_SENDER; + + // Pre-fetch all existing Tito invitations so duplicate recovery avoids per-person API calls + const existingInvitationsMap = await getAllRsvpInvitations(rsvpListSlug); + + const titoLimiter = createLimiter(TITO_CONCURRENCY); + const emailLimiter = createLimiter(EMAIL_CONCURRENCY); + + return processBulkInvites(csvText, { + label: 'Hacker', + + async preprocess(hackers) { + // Batch Hub duplicate check upfront — skip anyone already registered + const allEmails = hackers.map((h) => h.email); + const existingUsers = await GetManyUsers({ email: { $in: allEmails } }); + const existingEmailSet = new Set( + existingUsers.ok + ? existingUsers.body.map((u: { email: string }) => u.email) + : [] + ); + + const remaining: HackerInviteData[] = []; + const earlyResults: HackerInviteResult[] = []; + + for (const hacker of hackers) { + if (existingEmailSet.has(hacker.email)) { + earlyResults.push({ + email: hacker.email, + success: false, + error: 'User already exists.', + }); + } else { + remaining.push(hacker); + } + } + + return { remaining, earlyResults }; + }, + + async processOne(hacker) { + // Stage 1: Tito — uses pre-fetched map to short-circuit duplicate lookups + const titoResult = await titoLimiter(() => + getOrCreateTitoInvitation( + { ...hacker, rsvpListSlug, releaseIds }, + existingInvitationsMap + ) + ); + + if (!titoResult.ok) { + return { email: hacker.email, success: false, error: titoResult.error }; + } + + // Stage 2: Generate Hub invite link + const invite = await GenerateInvite( + { + email: hacker.email, + name: `${hacker.firstName} ${hacker.lastName}`, + role: 'hacker', + }, + 'invite' + ); + if (!invite.ok || !invite.body) { + return { + email: hacker.email, + success: false, + error: invite.error ?? 'Failed to generate Hub invite link.', + }; + } + + // Stage 3: Email — independent limiter + try { + await emailLimiter(() => + transporter.sendMail({ + from: sender, + to: hacker.email, + subject: HACKER_EMAIL_SUBJECT, + html: hackerInviteTemplate( + hacker.firstName, + titoResult.titoUrl, + invite.body! + ), + }) + ); + return { + email: hacker.email, + success: true, + titoUrl: titoResult.titoUrl, + inviteUrl: invite.body, + }; + } catch (e) { + const errorMsg = e instanceof Error ? e.message : 'Unknown error'; + return { + email: hacker.email, + success: false, + error: `Email send failed: ${errorMsg}`, + }; + } + }, + }); +} diff --git a/app/(api)/_actions/emails/sendSingleHackerInvite.ts b/app/(api)/_actions/emails/sendSingleHackerInvite.ts new file mode 100644 index 00000000..97821640 --- /dev/null +++ b/app/(api)/_actions/emails/sendSingleHackerInvite.ts @@ -0,0 +1,79 @@ +'use server'; + +import getOrCreateTitoInvitation from '@actions/tito/getOrCreateTitoInvitation'; +import GenerateInvite from '@datalib/invite/generateInvite'; +import { GetManyUsers } from '@datalib/users/getUser'; +import hackerInviteTemplate, { + HACKER_EMAIL_SUBJECT, +} from './emailTemplates/2026HackerInviteTemplate'; +import { DEFAULT_SENDER, transporter } from './transporter'; +import { HackerInviteData, SingleHackerInviteResponse } from '@typeDefs/emails'; + +interface HackerInviteOptions extends HackerInviteData { + rsvpListSlug: string; + releaseIds: string; +} + +export default async function sendSingleHackerInvite( + options: HackerInviteOptions +): Promise { + const { firstName, lastName, email, rsvpListSlug, releaseIds } = options; + + try { + // Step 1: Hub duplicate check + const users = await GetManyUsers({ email }); + if (users.ok && users.body.length > 0) { + throw new Error(`User with email ${email} already exists.`); + } + + // Step 2: Get or create Tito invitation + const titoResult = await getOrCreateTitoInvitation({ + firstName, + lastName, + email, + rsvpListSlug, + releaseIds, + }); + + if (!titoResult.ok) { + throw new Error(titoResult.error); + } + + // Step 3: Generate HMAC-signed Hub invite link + const invite = await GenerateInvite( + { email, name: `${firstName} ${lastName}`, role: 'hacker' }, + 'invite' + ); + if (!invite.ok || !invite.body) { + throw new Error(invite.error ?? 'Failed to generate invite link.'); + } + + if (!DEFAULT_SENDER) { + throw new Error('Email configuration missing: SENDER_EMAIL is not set.'); + } + + // Step 4: Send email with both URLs + await transporter.sendMail({ + from: DEFAULT_SENDER, + to: email, + subject: HACKER_EMAIL_SUBJECT, + html: hackerInviteTemplate(firstName, titoResult.titoUrl, invite.body), + }); + + return { + ok: true, + titoUrl: titoResult.titoUrl, + inviteUrl: invite.body, + error: null, + }; + } catch (e) { + const errorMessage = + e instanceof Error + ? e.message + : typeof e === 'string' + ? e + : 'Unknown error'; + console.error(`[Hacker Invite] Failed (${email}):`, errorMessage); + return { ok: false, error: errorMessage }; + } +} diff --git a/app/_types/emails.ts b/app/_types/emails.ts index 9f249baf..5354b2b0 100644 --- a/app/_types/emails.ts +++ b/app/_types/emails.ts @@ -35,6 +35,24 @@ export interface SingleJudgeInviteResponse { error: string | null; } +// Hacker invite types + +export type HackerInviteData = InviteData; + +export interface HackerInviteResult extends InviteResult { + titoUrl?: string; + inviteUrl?: string; +} + +export type BulkHackerInviteResponse = BulkInviteResponse; + +export interface SingleHackerInviteResponse { + ok: boolean; + titoUrl?: string; + inviteUrl?: string; + error: string | null; +} + // Mentor Hub invite types export type MentorInviteData = InviteData; From bcc8d8d6301bdb468e1ebeda57026dec9c39138c Mon Sep 17 00:00:00 2001 From: reehals Date: Fri, 1 May 2026 03:09:30 -0700 Subject: [PATCH 02/12] #515: Update MentorVolunteer forms to be role agnostic, add hackers to admin UI --- .../JudgeInvites/JudgeBulkInviteForm.tsx | 284 ------------------ .../JudgeInvites/JudgeSingleInviteForm.tsx | 96 ------ .../MentorVolunteerBulkInviteForm.tsx | 248 +++++++++------ .../MentorVolunteerInvitesPanel.tsx | 47 ++- .../MentorVolunteerSingleInviteForm.tsx | 206 ++++++++----- app/(pages)/admin/invites/page.tsx | 105 +++---- 6 files changed, 359 insertions(+), 627 deletions(-) delete mode 100644 app/(pages)/admin/_components/JudgeInvites/JudgeBulkInviteForm.tsx delete mode 100644 app/(pages)/admin/_components/JudgeInvites/JudgeSingleInviteForm.tsx diff --git a/app/(pages)/admin/_components/JudgeInvites/JudgeBulkInviteForm.tsx b/app/(pages)/admin/_components/JudgeInvites/JudgeBulkInviteForm.tsx deleted file mode 100644 index 22a1b4d7..00000000 --- a/app/(pages)/admin/_components/JudgeInvites/JudgeBulkInviteForm.tsx +++ /dev/null @@ -1,284 +0,0 @@ -'use client'; - -import { ChangeEvent, useState } from 'react'; -import { parse } from 'csv-parse/sync'; -import sendBulkJudgeHubInvites from '@actions/emails/sendBulkJudgeHubInvites'; -import { BulkJudgeInviteResponse, JudgeInviteData } from '@typeDefs/emails'; -import { - buildFailureDownloadFilename, - generateInviteFailuresCSV, -} from '../../_utils/generateInviteFailuresCSV'; - -/** - * Browser-safe CSV preview parser (no Node.js deps). Full validation runs server-side. - */ -function previewCSV( - text: string -): { ok: true; rows: JudgeInviteData[] } | { ok: false; error: string } { - if (!text.trim()) return { ok: false, error: 'CSV is empty.' }; - - let parsedRows: string[][]; - try { - parsedRows = parse(text, { - trim: true, - skip_empty_lines: true, - }) as string[][]; - } catch (error) { - return { - ok: false, - error: - error instanceof Error && error.message - ? `Could not parse CSV: ${error.message}` - : 'Could not parse CSV.', - }; - } - - if (parsedRows.length === 0) return { ok: false, error: 'CSV is empty.' }; - - const firstCells = parsedRows[0].map((cell) => cell.toLowerCase()); - const hasHeader = - firstCells.some((cell) => cell.includes('first')) || - firstCells.some((cell) => cell.includes('email')); - const dataRows = hasHeader ? parsedRows.slice(1) : parsedRows; - if (dataRows.length === 0) return { ok: false, error: 'No data rows found.' }; - - const previewRows: JudgeInviteData[] = []; - for (let i = 0; i < dataRows.length; i++) { - const cols = dataRows[i]; - if (cols.length < 3) { - return { - ok: false, - error: `Row ${hasHeader ? i + 2 : i + 1}: expected 3 columns, got ${ - cols.length - }.`, - }; - } - previewRows.push({ firstName: cols[0], lastName: cols[1], email: cols[2] }); - } - return { ok: true, rows: previewRows }; -} - -type Status = 'idle' | 'previewing' | 'sending' | 'done'; - -export default function JudgeBulkInviteForm() { - const [status, setStatus] = useState('idle'); - const [csvText, setCsvText] = useState(''); - const [fileName, setFileName] = useState(''); - const [preview, setPreview] = useState([]); - const [parseError, setParseError] = useState(''); - const [result, setResult] = useState(null); - - 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; - setFileName(file.name); - 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 () => { - setStatus('sending'); - setResult(null); - - const response = await sendBulkJudgeHubInvites(csvText); - setResult(response); - setStatus('done'); - }; - - const handleDownloadFailuresCSV = () => { - if (!result || result.failureCount === 0) return; - - const csv = generateInviteFailuresCSV(preview, result.results); - 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 = buildFailureDownloadFilename( - fileName || 'judge-invites.csv' - ); - link.click(); - URL.revokeObjectURL(url); - }; - - const handleReset = () => { - setStatus('idle'); - setCsvText(''); - setFileName(''); - setPreview([]); - setParseError(''); - setResult(null); - }; - - return ( -
- {/* File input */} -
- - -
- - {/* Parse error */} - {parseError && ( -
-

CSV errors:

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

- {preview.length} judge - {preview.length !== 1 ? 's' : ''} found. Review before sending: -

-
-
- - - - - - - - - - {preview.map((judge, i) => ( - - - - - - ))} - -
- First Name - - Last Name - - Email -
- {judge.firstName} - - {judge.lastName} - {judge.email}
-
-
- -
- )} - - {/* Sending spinner */} - {status === 'sending' && ( -
-
- Sending invites… -
- )} - - {/* Results */} - {status === 'done' && result && ( -
-
-
-

- {result.successCount} -

-

Sent

-
-
-

- {result.failureCount} -

-

Failed

-
-
- - {result.error && ( -
-

Batch error

-

{result.error}

-
- )} - - {result.failureCount > 0 && ( -
-

- Failed invites -

-
- {result.results - .filter((r) => !r.success) - .map((r, i) => ( -
- - {r.email} - - {r.error} -
- ))} -
-
- )} - -
- {result.failureCount > 0 && ( - - )} - -
-
- )} -
- ); -} diff --git a/app/(pages)/admin/_components/JudgeInvites/JudgeSingleInviteForm.tsx b/app/(pages)/admin/_components/JudgeInvites/JudgeSingleInviteForm.tsx deleted file mode 100644 index 8b5e00d2..00000000 --- a/app/(pages)/admin/_components/JudgeInvites/JudgeSingleInviteForm.tsx +++ /dev/null @@ -1,96 +0,0 @@ -'use client'; - -import { FormEvent, useState } from 'react'; -import sendSingleJudgeHubInvite from '@actions/emails/sendSingleJudgeHubInvite'; - -export default function JudgeSingleInviteForm() { - const [loading, setLoading] = useState(false); - const [inviteUrl, setInviteUrl] = useState(''); - const [error, setError] = useState(''); - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - setLoading(true); - setInviteUrl(''); - setError(''); - - const formData = new FormData(e.currentTarget); - const firstName = formData.get('firstName') as string; - const lastName = formData.get('lastName') as string; - const email = formData.get('email') as string; - - const result = await sendSingleJudgeHubInvite({ - firstName, - lastName, - email, - }); - - setLoading(false); - - if (result.ok) { - setInviteUrl(result.inviteUrl ?? ''); - (e.target as HTMLFormElement).reset(); - } else { - setError(result.error ?? 'An unexpected error occurred.'); - } - }; - - return ( -
-
-
- - -
-
- - -
-
- -
- - -
- - - - {error && ( -

- {error} -

- )} - {inviteUrl && ( -
-

- Invite sent! -

-

{inviteUrl}

-
- )} -
- ); -} diff --git a/app/(pages)/admin/_components/MentorVolunteerInvites/MentorVolunteerBulkInviteForm.tsx b/app/(pages)/admin/_components/MentorVolunteerInvites/MentorVolunteerBulkInviteForm.tsx index 22783db4..991b18d1 100644 --- a/app/(pages)/admin/_components/MentorVolunteerInvites/MentorVolunteerBulkInviteForm.tsx +++ b/app/(pages)/admin/_components/MentorVolunteerInvites/MentorVolunteerBulkInviteForm.tsx @@ -3,20 +3,42 @@ import { ChangeEvent, useState } from 'react'; import { parse } from 'csv-parse/sync'; import sendBulkMentorOrVolunteerInvites from '@actions/emails/sendBulkMentorOrVolunteerInvites'; -import { BulkMentorInviteResponse, MentorInviteData } from '@typeDefs/emails'; +import sendBulkHackerInvites from '@actions/emails/sendBulkHackerInvites'; +import sendBulkJudgeHubInvites from '@actions/emails/sendBulkJudgeHubInvites'; +import { InviteData } from '@typeDefs/emails'; import { Release, RsvpList } from '@typeDefs/tito'; import { buildFailureDownloadFilename, generateInviteFailuresCSV, } from '../../_utils/generateInviteFailuresCSV'; -import { generateInviteResultsCSV } from '../../_utils/generateInviteResultsCSV'; +import { + generateInviteResultsCSV, + InviteResultRow, +} from '../../_utils/generateInviteResultsCSV'; +import { InviteRole } from './MentorVolunteerInvitesPanel'; + +interface DisplayResult { + email: string; + success: boolean; + error?: string; + titoUrl?: string; + inviteUrl?: string; +} + +interface DisplayBulkResponse { + ok: boolean; + results: DisplayResult[]; + successCount: number; + failureCount: number; + error: string | null; +} /** * Browser-safe CSV preview parser (no Node.js deps). Full validation runs server-side. */ function previewCSV( text: string -): { ok: true; rows: MentorInviteData[] } | { ok: false; error: string } { +): { ok: true; rows: InviteData[] } | { ok: false; error: string } { if (!text.trim()) return { ok: false, error: 'CSV is empty.' }; let parsedRows: string[][]; @@ -44,7 +66,7 @@ function previewCSV( const dataRows = hasHeader ? parsedRows.slice(1) : parsedRows; if (dataRows.length === 0) return { ok: false, error: 'No data rows found.' }; - const previewRows: MentorInviteData[] = []; + const previewRows: InviteData[] = []; for (let i = 0; i < dataRows.length; i++) { const cols = dataRows[i]; if (cols.length < 3) { @@ -65,7 +87,7 @@ type Status = 'idle' | 'previewing' | 'sending' | 'done'; interface Props { rsvpLists: RsvpList[]; releases: Release[]; - role: 'mentor' | 'volunteer'; + role: InviteRole; } export default function MentorVolunteerBulkInviteForm({ @@ -76,15 +98,17 @@ export default function MentorVolunteerBulkInviteForm({ const [status, setStatus] = useState('idle'); const [csvText, setCsvText] = useState(''); const [fileName, setFileName] = useState(''); - const [preview, setPreview] = useState([]); + const [preview, setPreview] = useState([]); const [parseError, setParseError] = useState(''); - const [result, setResult] = useState(null); + const [result, setResult] = useState(null); const [selectedListSlug, setSelectedListSlug] = useState( rsvpLists[0]?.slug ?? '' ); const [selectedReleases, setSelectedReleases] = useState([]); const [configError, setConfigError] = useState(''); + const hasTito = role !== 'judge'; + const toggleRelease = (id: string) => setSelectedReleases((prev) => prev.includes(id) ? prev.filter((r) => r !== id) : [...prev, id] @@ -114,11 +138,11 @@ export default function MentorVolunteerBulkInviteForm({ }; const handleSend = async () => { - if (!selectedListSlug) { + if (hasTito && !selectedListSlug) { setConfigError('Please select an RSVP list.'); return; } - if (selectedReleases.length === 0) { + if (hasTito && selectedReleases.length === 0) { setConfigError('Please select at least one release.'); return; } @@ -126,12 +150,53 @@ export default function MentorVolunteerBulkInviteForm({ setStatus('sending'); setResult(null); - const response = await sendBulkMentorOrVolunteerInvites( - csvText, - selectedListSlug, - selectedReleases.join(','), - role - ); + let response: DisplayBulkResponse; + + if (role === 'judge') { + const r = await sendBulkJudgeHubInvites(csvText); + response = { + ...r, + results: r.results.map((res) => ({ + email: res.email, + success: res.success, + error: res.error, + inviteUrl: res.inviteUrl, + })), + }; + } else if (role === 'hacker') { + const r = await sendBulkHackerInvites( + csvText, + selectedListSlug, + selectedReleases.join(',') + ); + response = { + ...r, + results: r.results.map((res) => ({ + email: res.email, + success: res.success, + error: res.error, + titoUrl: res.titoUrl, + inviteUrl: res.inviteUrl, + })), + }; + } else { + const r = await sendBulkMentorOrVolunteerInvites( + csvText, + selectedListSlug, + selectedReleases.join(','), + role + ); + response = { + ...r, + results: r.results.map((res) => ({ + email: res.email, + success: res.success, + error: res.error, + titoUrl: res.titoUrl, + })), + }; + } + setResult(response); setStatus('done'); }; @@ -141,18 +206,20 @@ export default function MentorVolunteerBulkInviteForm({ const resultMap = new Map( result.results.map((r) => [r.email.toLowerCase(), r]) ); - const rows = preview.map((mentor) => { - const res = resultMap.get(mentor.email.toLowerCase()); + const includeHub = role === 'hacker' || role === 'judge'; + const rows: InviteResultRow[] = preview.map((person) => { + const res = resultMap.get(person.email.toLowerCase()); return { - firstName: mentor.firstName, - lastName: mentor.lastName, - email: mentor.email, + firstName: person.firstName, + lastName: person.lastName, + email: person.email, titoUrl: res?.titoUrl, + hubUrl: res?.inviteUrl, success: res?.success ?? false, error: res?.error, }; }); - const csv = generateInviteResultsCSV(rows); + const csv = generateInviteResultsCSV(rows, includeHub); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); @@ -224,8 +291,10 @@ export default function MentorVolunteerBulkInviteForm({

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

@@ -245,19 +314,19 @@ export default function MentorVolunteerBulkInviteForm({ - {preview.map((mentor, i) => ( + {preview.map((person, i) => ( - {mentor.firstName} + {person.firstName} - {mentor.lastName} + {person.lastName} - {mentor.email} + {person.email} ))} @@ -266,68 +335,71 @@ export default function MentorVolunteerBulkInviteForm({
- {/* RSVP List */} -
- - -
- - {/* Releases */} -
-
- - -
-
- {releases.map((release) => ( -
+ +
+ +
+
+ + +
+
+ {releases.map((release) => ( + + ))} +
+
+ + )} {configError && (

diff --git a/app/(pages)/admin/_components/MentorVolunteerInvites/MentorVolunteerInvitesPanel.tsx b/app/(pages)/admin/_components/MentorVolunteerInvites/MentorVolunteerInvitesPanel.tsx index 1c9d7669..9cbc96f2 100644 --- a/app/(pages)/admin/_components/MentorVolunteerInvites/MentorVolunteerInvitesPanel.tsx +++ b/app/(pages)/admin/_components/MentorVolunteerInvites/MentorVolunteerInvitesPanel.tsx @@ -9,18 +9,38 @@ import MentorVolunteerBulkInviteForm from './MentorVolunteerBulkInviteForm'; type Mode = 'single' | 'bulk'; +export type InviteRole = 'hacker' | 'judge' | 'mentor' | 'volunteer'; + interface Props { - role: 'mentor' | 'volunteer'; + role: InviteRole; } +const ROLE_LABELS: Record = { + hacker: 'Hacker', + judge: 'Judge', + mentor: 'Mentor', + volunteer: 'Volunteer', +}; + +const ROLE_NOTES: Partial> = { + judge: 'This template includes Judge Orientation materials.', + mentor: 'This template includes Mentor Orientation materials.', + hacker: + 'This template includes both a Tito e-ticket link and a HackDavis Hub registration link.', +}; + +const needsTito = (role: InviteRole) => role !== 'judge'; + export default function MentorVolunteerInvitesPanel({ role }: Props) { const [mode, setMode] = useState('single'); const [rsvpLists, setRsvpLists] = useState([]); const [releases, setReleases] = useState([]); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(needsTito(role)); const [loadError, setLoadError] = useState(''); useEffect(() => { + if (!needsTito(role)) return; + (async () => { const [rsvpRes, relRes] = await Promise.all([ getRsvpLists(), @@ -36,7 +56,7 @@ export default function MentorVolunteerInvitesPanel({ role }: Props) { } setLoading(false); })(); - }, []); + }, [role]); if (loading) { return ( @@ -55,6 +75,9 @@ export default function MentorVolunteerInvitesPanel({ role }: Props) { ); } + const label = ROLE_LABELS[role]; + const note = ROLE_NOTES[role]; + return (

{/* Single / Bulk toggle */} @@ -77,14 +100,10 @@ export default function MentorVolunteerInvitesPanel({ role }: Props) { {mode === 'single' ? (

- Send a Tito invite to a single {role} by entering their details - below. + Send an invite to a single {label.toLowerCase()} by entering their + details below.

- {role === 'mentor' && ( -

- Note: This template includes Mentor Orientation materials. -

- )} + {note &&

Note: {note}

} First Name, Last Name, Email {' '} - to send Tito invites to multiple {role}s at once. + to send invites to multiple {label.toLowerCase()}s at once.

- {role === 'mentor' && ( -

- Note: This template includes Mentor Orientation materials. -

- )} + {note &&

Note: {note}

} (null); const [error, setError] = useState(''); const [selectedListSlug, setSelectedListSlug] = useState( rsvpLists[0]?.slug ?? '' ); const [selectedReleases, setSelectedReleases] = useState([]); + const hasTito = role !== 'judge'; + const toggleRelease = (id: string) => setSelectedReleases((prev) => prev.includes(id) ? prev.filter((r) => r !== id) : [...prev, id] @@ -30,33 +40,57 @@ export default function MentorSingleInviteForm({ const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - if (!selectedListSlug) { + if (hasTito && !selectedListSlug) { setError('Please select an RSVP list.'); return; } - if (selectedReleases.length === 0) { + if (hasTito && selectedReleases.length === 0) { setError('Please select at least one release.'); return; } setLoading(true); - setTitoUrl(''); + setSuccessUrls(null); setError(''); const formData = new FormData(e.currentTarget); - const result = await sendSingleMentorOrVolunteerInvite({ - firstName: formData.get('firstName') as string, - lastName: formData.get('lastName') as string, - email: formData.get('email') as string, - rsvpListSlug: selectedListSlug, - releaseIds: selectedReleases.join(','), - role, - }); + const firstName = formData.get('firstName') as string; + const lastName = formData.get('lastName') as string; + const email = formData.get('email') as string; + + let result: { + ok: boolean; + titoUrl?: string; + inviteUrl?: string; + error: string | null; + }; + + if (role === 'judge') { + result = await sendSingleJudgeHubInvite({ firstName, lastName, email }); + } else if (role === 'hacker') { + result = await sendSingleHackerInvite({ + firstName, + lastName, + email, + rsvpListSlug: selectedListSlug, + releaseIds: selectedReleases.join(','), + }); + } else { + const mentorResult = await sendSingleMentorOrVolunteerInvite({ + firstName, + lastName, + email, + rsvpListSlug: selectedListSlug, + releaseIds: selectedReleases.join(','), + role, + }); + result = { ...mentorResult, inviteUrl: undefined }; + } setLoading(false); if (result.ok) { - setTitoUrl(result.titoUrl ?? ''); + setSuccessUrls({ titoUrl: result.titoUrl, inviteUrl: result.inviteUrl }); (e.target as HTMLFormElement).reset(); setSelectedReleases([]); } else { @@ -99,64 +133,71 @@ export default function MentorSingleInviteForm({ />
- {/* RSVP List */} -
- - -
- - {/* Releases */} -
-
- - -
-
- {releases.map((release) => ( -
+ +
+ +
+
+ + +
+
+ {releases.map((release) => ( + + ))} +
+
+ + )} ))}
- {/* Judges panel */} - {tab === 'judges' && ( -
-
-

Invite a Judge

-

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

-

- Note: This template includes Judge Orientation materials. Navigate{' '} - - here - {' '} - for one-time invites. -

- -
- -
- -
-

Bulk Invite Judges

-

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

-

- Note: This template includes Judge Orientation materials. Navigate{' '} - - here - {' '} - for one-time invites. -

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

Mentor Invites

- -
- )} - - {/* Volunteers panel */} - {tab === 'volunteers' && ( -
-

Volunteer Invites

- -
- )} +
+

+ {TAB_LABELS[tab]} Invites +

+

{TAB_DESCRIPTIONS[tab]}

+ +
); } From 9619b49845606177917dc0cdf3fb9de0c92c0778 Mon Sep 17 00:00:00 2001 From: reehals Date: Fri, 1 May 2026 03:10:38 -0700 Subject: [PATCH 03/12] #515 / #502 : Fix tito duplicate check latency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a pre-fetched existingInvitationsMap is provided (email→url): - If the email is already in the map, return the cached URL immediately without hitting the Tito API at all. - Otherwise, create normally. --- .../sendBulkMentorOrVolunteerInvites.ts | 13 +++--- .../_actions/tito/getAllRsvpInvitations.ts | 43 +++++++++++++++++++ .../tito/getOrCreateTitoInvitation.ts | 28 +++++++++--- 3 files changed, 74 insertions(+), 10 deletions(-) create mode 100644 app/(api)/_actions/tito/getAllRsvpInvitations.ts diff --git a/app/(api)/_actions/emails/sendBulkMentorOrVolunteerInvites.ts b/app/(api)/_actions/emails/sendBulkMentorOrVolunteerInvites.ts index 2d3363af..4f89e1ca 100644 --- a/app/(api)/_actions/emails/sendBulkMentorOrVolunteerInvites.ts +++ b/app/(api)/_actions/emails/sendBulkMentorOrVolunteerInvites.ts @@ -1,6 +1,7 @@ 'use server'; import getOrCreateTitoInvitation from '@actions/tito/getOrCreateTitoInvitation'; +import getAllRsvpInvitations from '@actions/tito/getAllRsvpInvitations'; import mentorInviteTemplate, { MENTOR_EMAIL_SUBJECT, } from './emailTemplates/2026MentorInviteTemplate'; @@ -39,6 +40,9 @@ export default async function sendBulkMentorOrVolunteerInvites( } const sender = DEFAULT_SENDER; + // Pre-fetch all existing invitations so duplicate recovery skips per-person API calls + const existingInvitationsMap = await getAllRsvpInvitations(rsvpListSlug); + const titoLimiter = createLimiter(TITO_CONCURRENCY); const emailLimiter = createLimiter(EMAIL_CONCURRENCY); @@ -48,11 +52,10 @@ export default async function sendBulkMentorOrVolunteerInvites( async processOne(mentor) { // Stage 1: Tito — slot released before email starts const titoResult = await titoLimiter(() => - getOrCreateTitoInvitation({ - ...mentor, - rsvpListSlug, - releaseIds, - }) + getOrCreateTitoInvitation( + { ...mentor, rsvpListSlug, releaseIds }, + existingInvitationsMap + ) ); if (!titoResult.ok) { diff --git a/app/(api)/_actions/tito/getAllRsvpInvitations.ts b/app/(api)/_actions/tito/getAllRsvpInvitations.ts new file mode 100644 index 00000000..9152d25b --- /dev/null +++ b/app/(api)/_actions/tito/getAllRsvpInvitations.ts @@ -0,0 +1,43 @@ +'use server'; + +import { ReleaseInvitation } from '@typeDefs/tito'; +import { TitoRequest } from './titoClient'; + +/** + * Fetches all existing invitations for an RSVP list and returns a map of + * normalized email → titoUrl. Used to short-circuit duplicate API lookups + * during bulk sends. + */ +export default async function getAllRsvpInvitations( + rsvpListSlug: string +): Promise> { + const map = new Map(); + const pageSize = 1000; + let page = 1; + + while (true) { + try { + const data = await TitoRequest<{ + release_invitations: ReleaseInvitation[]; + }>( + `/rsvp_lists/${rsvpListSlug}/release_invitations?page[size]=${pageSize}&page[number]=${page}` + ); + const invitations = data.release_invitations ?? []; + + for (const inv of invitations) { + if (inv.email) { + const url = inv.unique_url ?? inv.url; + if (url) map.set(inv.email.toLowerCase(), url); + } + } + + if (invitations.length < pageSize) break; + page++; + } catch (e) { + console.error('[Tito] getAllRsvpInvitations failed on page', page, e); + break; + } + } + + return map; +} diff --git a/app/(api)/_actions/tito/getOrCreateTitoInvitation.ts b/app/(api)/_actions/tito/getOrCreateTitoInvitation.ts index 186829d6..e6adbf8c 100644 --- a/app/(api)/_actions/tito/getOrCreateTitoInvitation.ts +++ b/app/(api)/_actions/tito/getOrCreateTitoInvitation.ts @@ -20,18 +20,36 @@ function isDuplicateTicketError(error: string | null | undefined): boolean { } /** - * Wrapper function to create a Tito invitation with duplicate handling: - * If duplicate error detected, it will first try to reuse the existing invitation URL. - * If that fails, it will delete the existing invitation and attempt to create a new one. + * Wrapper function to create a Tito invitation with duplicate handling. + * + * When a pre-fetched existingInvitationsMap is provided (email→url): + * - If the email is already in the map, return the cached URL immediately + * without hitting the Tito API at all. + * - Otherwise, create normally. + * + * When no map is provided (single invites): + * - Attempt to create; on duplicate error, fall back to a live + * getRsvpInvitationByEmail lookup, then delete+recreate if no URL found. + * + * For non-duplicate failures, the error is returned as-is (caller may retry). */ export default async function getOrCreateTitoInvitation( - data: ReleaseInvitationRequest + data: ReleaseInvitationRequest, + existingInvitationsMap?: Map ): Promise<{ ok: true; titoUrl: string } | { ok: false; error: string }> { const { email, rsvpListSlug } = data; + // Skip the create call entirely if we already know this email has a ticket + if (existingInvitationsMap) { + const cached = existingInvitationsMap.get(email.toLowerCase()); + if (cached) { + return { ok: true, titoUrl: cached }; + } + } + let titoResponse = await createRsvpInvitation(data); - // Duplicate recovery: reuse existing URL if possible, otherwise delete + recreate + // Duplicate recovery for single-invite path (no pre-fetched map) if (!titoResponse.ok && isDuplicateTicketError(titoResponse.error)) { console.warn(`[Tito] Duplicate detected for ${email}, attempting recovery`); From ca8ce10bd2204fdd4c3980565024ee045500d2e1 Mon Sep 17 00:00:00 2001 From: reehals Date: Fri, 1 May 2026 03:15:52 -0700 Subject: [PATCH 04/12] #515: Rename files to be role agnostic --- .../BulkInviteForm.tsx} | 8 ++------ .../InvitePanel.tsx} | 10 +++++----- .../SingleInviteForm.tsx} | 8 ++------ app/(pages)/admin/invites/page.tsx | 4 ++-- 4 files changed, 11 insertions(+), 19 deletions(-) rename app/(pages)/admin/_components/{MentorVolunteerInvites/MentorVolunteerBulkInviteForm.tsx => InvitePanel/BulkInviteForm.tsx} (99%) rename app/(pages)/admin/_components/{MentorVolunteerInvites/MentorVolunteerInvitesPanel.tsx => InvitePanel/InvitePanel.tsx} (92%) rename app/(pages)/admin/_components/{MentorVolunteerInvites/MentorVolunteerSingleInviteForm.tsx => InvitePanel/SingleInviteForm.tsx} (97%) diff --git a/app/(pages)/admin/_components/MentorVolunteerInvites/MentorVolunteerBulkInviteForm.tsx b/app/(pages)/admin/_components/InvitePanel/BulkInviteForm.tsx similarity index 99% rename from app/(pages)/admin/_components/MentorVolunteerInvites/MentorVolunteerBulkInviteForm.tsx rename to app/(pages)/admin/_components/InvitePanel/BulkInviteForm.tsx index 991b18d1..7d4ea170 100644 --- a/app/(pages)/admin/_components/MentorVolunteerInvites/MentorVolunteerBulkInviteForm.tsx +++ b/app/(pages)/admin/_components/InvitePanel/BulkInviteForm.tsx @@ -15,7 +15,7 @@ import { generateInviteResultsCSV, InviteResultRow, } from '../../_utils/generateInviteResultsCSV'; -import { InviteRole } from './MentorVolunteerInvitesPanel'; +import { InviteRole } from './InvitePanel'; interface DisplayResult { email: string; @@ -90,11 +90,7 @@ interface Props { role: InviteRole; } -export default function MentorVolunteerBulkInviteForm({ - rsvpLists, - releases, - role, -}: Props) { +export default function BulkInviteForm({ rsvpLists, releases, role }: Props) { const [status, setStatus] = useState('idle'); const [csvText, setCsvText] = useState(''); const [fileName, setFileName] = useState(''); diff --git a/app/(pages)/admin/_components/MentorVolunteerInvites/MentorVolunteerInvitesPanel.tsx b/app/(pages)/admin/_components/InvitePanel/InvitePanel.tsx similarity index 92% rename from app/(pages)/admin/_components/MentorVolunteerInvites/MentorVolunteerInvitesPanel.tsx rename to app/(pages)/admin/_components/InvitePanel/InvitePanel.tsx index 9cbc96f2..54ec5961 100644 --- a/app/(pages)/admin/_components/MentorVolunteerInvites/MentorVolunteerInvitesPanel.tsx +++ b/app/(pages)/admin/_components/InvitePanel/InvitePanel.tsx @@ -4,8 +4,8 @@ import { useEffect, useState } from 'react'; import getRsvpLists from '@actions/tito/getRsvpLists'; import getReleases from '@actions/tito/getReleases'; import { Release, RsvpList } from '@typeDefs/tito'; -import MentorVolunteerSingleInviteForm from './MentorVolunteerSingleInviteForm'; -import MentorVolunteerBulkInviteForm from './MentorVolunteerBulkInviteForm'; +import SingleInviteForm from './SingleInviteForm'; +import BulkInviteForm from './BulkInviteForm'; type Mode = 'single' | 'bulk'; @@ -31,7 +31,7 @@ const ROLE_NOTES: Partial> = { const needsTito = (role: InviteRole) => role !== 'judge'; -export default function MentorVolunteerInvitesPanel({ role }: Props) { +export default function InvitePanel({ role }: Props) { const [mode, setMode] = useState('single'); const [rsvpLists, setRsvpLists] = useState([]); const [releases, setReleases] = useState([]); @@ -104,7 +104,7 @@ export default function MentorVolunteerInvitesPanel({ role }: Props) { details below.

{note &&

Note: {note}

} - {note &&

Note: {note}

} - (null); const [error, setError] = useState(''); diff --git a/app/(pages)/admin/invites/page.tsx b/app/(pages)/admin/invites/page.tsx index e2c31e19..242e9658 100644 --- a/app/(pages)/admin/invites/page.tsx +++ b/app/(pages)/admin/invites/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState } from 'react'; -import MentorVolunteerInvitesPanel from '../_components/MentorVolunteerInvites/MentorVolunteerInvitesPanel'; +import InvitePanel from '../_components/InvitePanel/InvitePanel'; type Tab = 'hackers' | 'judges' | 'mentors' | 'volunteers'; @@ -50,7 +50,7 @@ export default function InvitesPage() { {TAB_LABELS[tab]} Invites

{TAB_DESCRIPTIONS[tab]}

- Date: Fri, 1 May 2026 03:19:01 -0700 Subject: [PATCH 05/12] #515: Fix build (no constant condition) --- app/(api)/_actions/tito/getAllRsvpInvitations.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/(api)/_actions/tito/getAllRsvpInvitations.ts b/app/(api)/_actions/tito/getAllRsvpInvitations.ts index 9152d25b..baea87a5 100644 --- a/app/(api)/_actions/tito/getAllRsvpInvitations.ts +++ b/app/(api)/_actions/tito/getAllRsvpInvitations.ts @@ -15,7 +15,8 @@ export default async function getAllRsvpInvitations( const pageSize = 1000; let page = 1; - while (true) { + let hasMore = true; + while (hasMore) { try { const data = await TitoRequest<{ release_invitations: ReleaseInvitation[]; @@ -31,11 +32,11 @@ export default async function getAllRsvpInvitations( } } - if (invitations.length < pageSize) break; + hasMore = invitations.length === pageSize; page++; } catch (e) { console.error('[Tito] getAllRsvpInvitations failed on page', page, e); - break; + hasMore = false; } } From 4cca71fe22cb71e3d76f6ff923656efdaea3e5d3 Mon Sep 17 00:00:00 2001 From: reehals Date: Fri, 1 May 2026 13:39:49 -0700 Subject: [PATCH 06/12] #515 : Add waitlist, waitlist_accept, reject emails --- .../2026HackerRejectionTemplate.ts | 58 +++++ .../2026HackerWaitlistAcceptTemplate.ts | 86 ++++++ .../2026HackerWaitlistTemplate.ts | 58 +++++ .../emails/parseHackerAdmissionsCSV.ts | 116 +++++++++ .../_actions/emails/processBulkInvites.ts | 14 +- .../_actions/emails/sendBulkHackerInvites.ts | 191 +++++++++----- .../_actions/emails/sendSingleHackerInvite.ts | 145 ++++++++--- .../InvitePanel/BulkInviteForm.tsx | 246 +++++++++++++++--- .../_components/InvitePanel/InvitePanel.tsx | 17 +- .../InvitePanel/SingleInviteForm.tsx | 79 +++++- .../admin/_utils/generateInviteResultsCSV.ts | 11 +- app/_types/emails.ts | 31 ++- 12 files changed, 892 insertions(+), 160 deletions(-) create mode 100644 app/(api)/_actions/emails/emailTemplates/2026HackerRejectionTemplate.ts create mode 100644 app/(api)/_actions/emails/emailTemplates/2026HackerWaitlistAcceptTemplate.ts create mode 100644 app/(api)/_actions/emails/emailTemplates/2026HackerWaitlistTemplate.ts create mode 100644 app/(api)/_actions/emails/parseHackerAdmissionsCSV.ts diff --git a/app/(api)/_actions/emails/emailTemplates/2026HackerRejectionTemplate.ts b/app/(api)/_actions/emails/emailTemplates/2026HackerRejectionTemplate.ts new file mode 100644 index 00000000..5d974214 --- /dev/null +++ b/app/(api)/_actions/emails/emailTemplates/2026HackerRejectionTemplate.ts @@ -0,0 +1,58 @@ +export const HACKER_REJECTION_EMAIL_SUBJECT = + 'HackDavis 2026 — Application Update'; + +export default function hackerRejectionTemplate(fname: string) { + const HEADER_IMAGE_URL = `${process.env.BASE_URL}/email/2026_header.png`; + const FOOTER_IMAGE_URL = `${process.env.BASE_URL}/email/2026_footer.png`; + const DOE = 'May 9 - 10, 2026'; + const DOE_LOCATION = 'University Credit Union Center, UC Davis'; + + return ` + + + + + ${HACKER_REJECTION_EMAIL_SUBJECT} + + + +
+ HackDavis 2026 header +

+ HackDavis 2026 — + Application Update +

+

✦ ${DOE} ✦ ${DOE_LOCATION}

+
+
+

Hi ${fname},

+
+

Thank you for applying to HackDavis 2026 — California's largest social good hackathon. We truly appreciate the time and effort you put into your application.

+

After careful review, we're sorry to let you know that we're unable to offer you a spot at HackDavis 2026. We received an overwhelming number of applications this year, and the decisions were incredibly difficult.

+

We hope this doesn't discourage you — the hackathon community is always growing, and we'd love to see you apply again in the future. Keep building!

+

If you have any questions, feel free to reply to this email.

+
+

Thank you,
The HackDavis Team

+
+
+ HackDavis 2026 footer +
+ +`; +} diff --git a/app/(api)/_actions/emails/emailTemplates/2026HackerWaitlistAcceptTemplate.ts b/app/(api)/_actions/emails/emailTemplates/2026HackerWaitlistAcceptTemplate.ts new file mode 100644 index 00000000..abd119fc --- /dev/null +++ b/app/(api)/_actions/emails/emailTemplates/2026HackerWaitlistAcceptTemplate.ts @@ -0,0 +1,86 @@ +export const HACKER_WAITLIST_ACCEPT_EMAIL_SUBJECT = + "[ACTION REQUIRED] You're off the waitlist — HackDavis 2026"; + +export default function hackerWaitlistAcceptTemplate( + fname: string, + titoUrl: string, + hubInviteUrl: string +) { + const HEADER_IMAGE_URL = `${process.env.BASE_URL}/email/2026_header.png`; + const FOOTER_IMAGE_URL = `${process.env.BASE_URL}/email/2026_footer.png`; + const DISCORD_SERVER_URL = 'https://discord.gg/wc6QQEc'; + const CLAIM_TITO_TICKET_DEADLINE = '11:59pm on May 4th'; + const DOE_DATE = 'May 9th, 2026'; + const DOE = 'May 9 - 10, 2026'; + const DOE_LOCATION = 'University Credit Union Center, UC Davis'; + + return ` + + + + + ${HACKER_WAITLIST_ACCEPT_EMAIL_SUBJECT} + + + +
+ HackDavis 2026 header +

+ You're off the waitlist — + Welcome to HackDavis 2026! +

+

✦ ${DOE} ✦ ${DOE_LOCATION}

+
+
+

Hi ${fname},

+
+

Great news — a spot has opened up and you've been let off the waitlist for HackDavis 2026! We're thrilled to have you join us for a weekend of building, learning, and making an impact.

+

IMPORTANT NEXT STEPS:

+
+

1️⃣ Claim your E-Ticket by ${CLAIM_TITO_TICKET_DEADLINE}

+

👉 Tito Ticket: ${titoUrl}

+

Please use this unique invite link to claim your e-ticket. Do NOT share it with anyone else. You will be asked to show your e-ticket at the check-in table to enter the venue.

+
+
+

2️⃣ Register on HackDavis Hub by ${DOE_DATE}

+

👉 Hub Invite: ${hubInviteUrl}

+

HackDavis Hub is where you'll get prize and track information, view the schedule, and track judging. Use this unique link to create your account. Do NOT share it with anyone else.

+
+
+

3️⃣ Join our Discord server

+

👉 Discord Server: ${DISCORD_SERVER_URL}

+

We'll be using Discord as our main space for announcements, updates, and support during the event. You can use it to:

+

🔹 Get quick answers from the HackDavis team

+

🔹 Stay in the loop on event updates

+

🔹 Connect with other hackers, mentors & sponsors

+
+

Please feel free to reach out if you have any questions or concerns. We can't wait to see what you build!

+
+

Thank you,
The HackDavis Team

+
+
+ HackDavis 2026 footer +
+ +`; +} diff --git a/app/(api)/_actions/emails/emailTemplates/2026HackerWaitlistTemplate.ts b/app/(api)/_actions/emails/emailTemplates/2026HackerWaitlistTemplate.ts new file mode 100644 index 00000000..5c5d10a3 --- /dev/null +++ b/app/(api)/_actions/emails/emailTemplates/2026HackerWaitlistTemplate.ts @@ -0,0 +1,58 @@ +export const HACKER_WAITLIST_EMAIL_SUBJECT = + "HackDavis 2026 — You're on the Waitlist"; + +export default function hackerWaitlistTemplate(fname: string) { + const HEADER_IMAGE_URL = `${process.env.BASE_URL}/email/2026_header.png`; + const FOOTER_IMAGE_URL = `${process.env.BASE_URL}/email/2026_footer.png`; + const DOE = 'May 9 - 10, 2026'; + const DOE_LOCATION = 'University Credit Union Center, UC Davis'; + + return ` + + + + + ${HACKER_WAITLIST_EMAIL_SUBJECT} + + + +
+ HackDavis 2026 header +

+ HackDavis 2026 — + Waitlist Update +

+

✦ ${DOE} ✦ ${DOE_LOCATION}

+
+
+

Hi ${fname},

+
+

Thank you so much for applying to HackDavis 2026 — California's largest social good hackathon!

+

After reviewing your application, we've placed you on our waitlist. While we weren't able to offer you a spot right away, you're still in the running — we'll reach out if a place opens up.

+

We know this isn't the news you were hoping for, and we genuinely appreciate your interest in HackDavis. The quality of applications we received this year made decisions incredibly difficult.

+

If you have any questions, feel free to reply to this email. We hope to see you at HackDavis!

+
+

Thank you,
The HackDavis Team

+
+
+ HackDavis 2026 footer +
+ +`; +} diff --git a/app/(api)/_actions/emails/parseHackerAdmissionsCSV.ts b/app/(api)/_actions/emails/parseHackerAdmissionsCSV.ts new file mode 100644 index 00000000..ab793dca --- /dev/null +++ b/app/(api)/_actions/emails/parseHackerAdmissionsCSV.ts @@ -0,0 +1,116 @@ +import { parse } from 'csv-parse/sync'; +import { z } from 'zod'; +import { + HackerInviteData, + HackerAdmissionType, + HACKER_ADMISSION_TYPES, +} from '@typeDefs/emails'; + +const emailSchema = z.string().email(); + +const VALID_TYPES = new Set(HACKER_ADMISSION_TYPES); + +function normalizeType(raw: string): HackerAdmissionType | null { + const normalized = raw.trim().toLowerCase().replace(/[- ]/g, '_'); + return VALID_TYPES.has(normalized) + ? (normalized as HackerAdmissionType) + : null; +} + +interface ParseResult { + ok: true; + body: HackerInviteData[]; +} + +interface ParseError { + ok: false; + error: string; +} + +/** + * Parses a hacker admissions CSV with columns: + * First Name, Last Name, Email, Type + * + * Type must be one of: accept, waitlist_accept, waitlist, reject + */ +export default function parseHackerAdmissionsCSV( + csvText: string +): ParseResult | ParseError { + try { + if (!csvText.trim()) { + return { ok: false, error: 'CSV file is empty.' }; + } + + const rows: string[][] = parse(csvText, { + trim: true, + skip_empty_lines: true, + }); + + if (rows.length === 0) { + return { ok: false, error: 'CSV file has no rows.' }; + } + + const firstRow = rows[0].map((cell) => cell.toLowerCase()); + const hasHeader = + firstRow.some((cell) => cell.includes('first')) || + firstRow.some((cell) => cell.includes('email')); + const dataRows = hasHeader ? rows.slice(1) : rows; + + if (dataRows.length === 0) { + return { ok: false, error: 'CSV has a header but no data rows.' }; + } + + const results: HackerInviteData[] = []; + const errors: string[] = []; + + for (let i = 0; i < dataRows.length; i++) { + const row = dataRows[i]; + const rowNum = hasHeader ? i + 2 : i + 1; + + if (row.length < 4) { + errors.push( + `Row ${rowNum}: expected 4 columns (First Name, Last Name, Email, Type), got ${row.length}.` + ); + continue; + } + + const [firstName, lastName, email, typeRaw] = row; + + if (!firstName) { + errors.push(`Row ${rowNum}: First Name is empty.`); + continue; + } + if (!lastName) { + errors.push(`Row ${rowNum}: Last Name is empty.`); + continue; + } + + const emailResult = emailSchema.safeParse(email); + if (!emailResult.success) { + errors.push(`Row ${rowNum}: "${email}" is not a valid email address.`); + continue; + } + + const admissionType = normalizeType(typeRaw ?? ''); + if (!admissionType) { + errors.push( + `Row ${rowNum}: "${typeRaw}" is not a valid type. Must be one of: ${HACKER_ADMISSION_TYPES.join( + ', ' + )}.` + ); + continue; + } + + results.push({ firstName, lastName, email, admissionType }); + } + + if (errors.length > 0) { + return { ok: false, error: errors.join('\n') }; + } + + return { ok: true, body: results }; + } catch (e) { + const error = e as Error; + return { ok: false, error: `Failed to parse CSV: ${error.message}` }; + } +} diff --git a/app/(api)/_actions/emails/processBulkInvites.ts b/app/(api)/_actions/emails/processBulkInvites.ts index f4b949c4..87b77e52 100644 --- a/app/(api)/_actions/emails/processBulkInvites.ts +++ b/app/(api)/_actions/emails/processBulkInvites.ts @@ -2,11 +2,17 @@ import parseInviteCSV from './parseInviteCSV'; import createLimiter from './createLimiter'; import { InviteData, InviteResult, BulkInviteResponse } from '@typeDefs/emails'; +type ParseFn = ( + csvText: string +) => { ok: true; body: TData[] } | { ok: false; error: string }; + export interface BulkInviteConfig< TData extends InviteData, TResult extends InviteResult, > { label: string; + /** Override the default CSV parser (parseInviteCSV) with a custom one. */ + parse?: ParseFn; preprocess?: (items: TData[]) => Promise<{ remaining: TData[]; earlyResults: TResult[]; @@ -22,10 +28,12 @@ export default async function processBulkInvites< csvText: string, config: BulkInviteConfig ): Promise> { - const { label, preprocess, processOne, concurrency } = config; + const { label, parse, preprocess, processOne, concurrency } = config; - // Parse CSV - const parsed = parseInviteCSV(csvText); + // Parse CSV — use custom parser if provided, otherwise fall back to generic + const parsed = parse + ? parse(csvText) + : (parseInviteCSV(csvText) as ReturnType>); if (!parsed.ok) { return { ok: false, diff --git a/app/(api)/_actions/emails/sendBulkHackerInvites.ts b/app/(api)/_actions/emails/sendBulkHackerInvites.ts index cae29c95..54cf8068 100644 --- a/app/(api)/_actions/emails/sendBulkHackerInvites.ts +++ b/app/(api)/_actions/emails/sendBulkHackerInvites.ts @@ -7,13 +7,24 @@ import { GetManyUsers } from '@datalib/users/getUser'; import hackerInviteTemplate, { HACKER_EMAIL_SUBJECT, } from './emailTemplates/2026HackerInviteTemplate'; +import hackerWaitlistAcceptTemplate, { + HACKER_WAITLIST_ACCEPT_EMAIL_SUBJECT, +} from './emailTemplates/2026HackerWaitlistAcceptTemplate'; +import hackerWaitlistTemplate, { + HACKER_WAITLIST_EMAIL_SUBJECT, +} from './emailTemplates/2026HackerWaitlistTemplate'; +import hackerRejectionTemplate, { + HACKER_REJECTION_EMAIL_SUBJECT, +} from './emailTemplates/2026HackerRejectionTemplate'; import { DEFAULT_SENDER, transporter } from './transporter'; import createLimiter from './createLimiter'; import processBulkInvites from './processBulkInvites'; +import parseHackerAdmissionsCSV from './parseHackerAdmissionsCSV'; import { BulkHackerInviteResponse, HackerInviteData, HackerInviteResult, + admissionNeedsTitoAndHub, } from '@typeDefs/emails'; const TITO_CONCURRENCY = 20; @@ -35,7 +46,7 @@ export default async function sendBulkHackerInvites( } const sender = DEFAULT_SENDER; - // Pre-fetch all existing Tito invitations so duplicate recovery avoids per-person API calls + // Pre-fetch all existing Tito invitations for rows that need them const existingInvitationsMap = await getAllRsvpInvitations(rsvpListSlug); const titoLimiter = createLimiter(TITO_CONCURRENCY); @@ -43,24 +54,37 @@ export default async function sendBulkHackerInvites( return processBulkInvites(csvText, { label: 'Hacker', + parse: parseHackerAdmissionsCSV, async preprocess(hackers) { - // Batch Hub duplicate check upfront — skip anyone already registered - const allEmails = hackers.map((h) => h.email); - const existingUsers = await GetManyUsers({ email: { $in: allEmails } }); - const existingEmailSet = new Set( - existingUsers.ok - ? existingUsers.body.map((u: { email: string }) => u.email) - : [] - ); + // Only batch-check Hub duplicates for rows that will create Hub accounts + const acceptEmails = hackers + .filter((h) => admissionNeedsTitoAndHub(h.admissionType)) + .map((h) => h.email); + + const existingEmailSet = new Set(); + if (acceptEmails.length > 0) { + const existingUsers = await GetManyUsers({ + email: { $in: acceptEmails }, + }); + if (existingUsers.ok) { + for (const u of existingUsers.body as { email: string }[]) { + existingEmailSet.add(u.email); + } + } + } const remaining: HackerInviteData[] = []; const earlyResults: HackerInviteResult[] = []; for (const hacker of hackers) { - if (existingEmailSet.has(hacker.email)) { + if ( + admissionNeedsTitoAndHub(hacker.admissionType) && + existingEmailSet.has(hacker.email) + ) { earlyResults.push({ email: hacker.email, + admissionType: hacker.admissionType, success: false, error: 'User already exists.', }); @@ -73,62 +97,103 @@ export default async function sendBulkHackerInvites( }, async processOne(hacker) { - // Stage 1: Tito — uses pre-fetched map to short-circuit duplicate lookups - const titoResult = await titoLimiter(() => - getOrCreateTitoInvitation( - { ...hacker, rsvpListSlug, releaseIds }, - existingInvitationsMap - ) - ); - - if (!titoResult.ok) { - return { email: hacker.email, success: false, error: titoResult.error }; - } + const { firstName, lastName, email, admissionType } = hacker; + const needsLinks = admissionNeedsTitoAndHub(admissionType); - // Stage 2: Generate Hub invite link - const invite = await GenerateInvite( - { - email: hacker.email, - name: `${hacker.firstName} ${hacker.lastName}`, - role: 'hacker', - }, - 'invite' - ); - if (!invite.ok || !invite.body) { - return { - email: hacker.email, - success: false, - error: invite.error ?? 'Failed to generate Hub invite link.', - }; - } + if (needsLinks) { + // Stage 1: Tito + const titoResult = await titoLimiter(() => + getOrCreateTitoInvitation( + { firstName, lastName, email, rsvpListSlug, releaseIds }, + existingInvitationsMap + ) + ); + if (!titoResult.ok) { + return { + email, + admissionType, + success: false, + error: titoResult.error, + }; + } - // Stage 3: Email — independent limiter - try { - await emailLimiter(() => - transporter.sendMail({ - from: sender, - to: hacker.email, - subject: HACKER_EMAIL_SUBJECT, - html: hackerInviteTemplate( - hacker.firstName, - titoResult.titoUrl, - invite.body! - ), - }) + // Stage 2: Hub invite link + const invite = await GenerateInvite( + { email, name: `${firstName} ${lastName}`, role: 'hacker' }, + 'invite' ); - return { - email: hacker.email, - success: true, - titoUrl: titoResult.titoUrl, - inviteUrl: invite.body, - }; - } catch (e) { - const errorMsg = e instanceof Error ? e.message : 'Unknown error'; - return { - email: hacker.email, - success: false, - error: `Email send failed: ${errorMsg}`, - }; + if (!invite.ok || !invite.body) { + return { + email, + admissionType, + success: false, + error: invite.error ?? 'Failed to generate Hub invite link.', + }; + } + + // Stage 3: Email + const subject = + admissionType === 'waitlist_accept' + ? HACKER_WAITLIST_ACCEPT_EMAIL_SUBJECT + : HACKER_EMAIL_SUBJECT; + + const html = + admissionType === 'waitlist_accept' + ? hackerWaitlistAcceptTemplate( + firstName, + titoResult.titoUrl, + invite.body! + ) + : hackerInviteTemplate(firstName, titoResult.titoUrl, invite.body!); + + try { + await emailLimiter(() => + transporter.sendMail({ from: sender, to: email, subject, html }) + ); + return { + email, + admissionType, + success: true, + titoUrl: titoResult.titoUrl, + inviteUrl: invite.body, + }; + } catch (e) { + return { + email, + admissionType, + success: false, + error: `Email send failed: ${ + e instanceof Error ? e.message : 'Unknown error' + }`, + }; + } + } else { + // Waitlist / reject: email only + const subject = + admissionType === 'waitlist' + ? HACKER_WAITLIST_EMAIL_SUBJECT + : HACKER_REJECTION_EMAIL_SUBJECT; + + const html = + admissionType === 'waitlist' + ? hackerWaitlistTemplate(firstName) + : hackerRejectionTemplate(firstName); + + try { + await emailLimiter(() => + transporter.sendMail({ from: sender, to: email, subject, html }) + ); + return { email, admissionType, success: true }; + } catch (e) { + return { + email, + admissionType, + success: false, + error: `Email send failed: ${ + e instanceof Error ? e.message : 'Unknown error' + }`, + }; + } } }, }); diff --git a/app/(api)/_actions/emails/sendSingleHackerInvite.ts b/app/(api)/_actions/emails/sendSingleHackerInvite.ts index 97821640..93c74a08 100644 --- a/app/(api)/_actions/emails/sendSingleHackerInvite.ts +++ b/app/(api)/_actions/emails/sendSingleHackerInvite.ts @@ -6,8 +6,22 @@ import { GetManyUsers } from '@datalib/users/getUser'; import hackerInviteTemplate, { HACKER_EMAIL_SUBJECT, } from './emailTemplates/2026HackerInviteTemplate'; +import hackerWaitlistAcceptTemplate, { + HACKER_WAITLIST_ACCEPT_EMAIL_SUBJECT, +} from './emailTemplates/2026HackerWaitlistAcceptTemplate'; +import hackerWaitlistTemplate, { + HACKER_WAITLIST_EMAIL_SUBJECT, +} from './emailTemplates/2026HackerWaitlistTemplate'; +import hackerRejectionTemplate, { + HACKER_REJECTION_EMAIL_SUBJECT, +} from './emailTemplates/2026HackerRejectionTemplate'; import { DEFAULT_SENDER, transporter } from './transporter'; -import { HackerInviteData, SingleHackerInviteResponse } from '@typeDefs/emails'; +import { + HackerInviteData, + // HackerAdmissionType, + SingleHackerInviteResponse, + admissionNeedsTitoAndHub, +} from '@typeDefs/emails'; interface HackerInviteOptions extends HackerInviteData { rsvpListSlug: string; @@ -17,55 +31,97 @@ interface HackerInviteOptions extends HackerInviteData { export default async function sendSingleHackerInvite( options: HackerInviteOptions ): Promise { - const { firstName, lastName, email, rsvpListSlug, releaseIds } = options; + const { + firstName, + lastName, + email, + admissionType, + rsvpListSlug, + releaseIds, + } = options; try { - // Step 1: Hub duplicate check - const users = await GetManyUsers({ email }); - if (users.ok && users.body.length > 0) { - throw new Error(`User with email ${email} already exists.`); + if (!DEFAULT_SENDER) { + throw new Error('Email configuration missing: SENDER_EMAIL is not set.'); } - // Step 2: Get or create Tito invitation - const titoResult = await getOrCreateTitoInvitation({ - firstName, - lastName, - email, - rsvpListSlug, - releaseIds, - }); + const needsLinks = admissionNeedsTitoAndHub(admissionType); - if (!titoResult.ok) { - throw new Error(titoResult.error); - } + if (needsLinks) { + // Accept / waitlist_accept: Hub duplicate check → Tito → Hub invite → email + const users = await GetManyUsers({ email }); + if (users.ok && users.body.length > 0) { + throw new Error(`User with email ${email} already exists.`); + } - // Step 3: Generate HMAC-signed Hub invite link - const invite = await GenerateInvite( - { email, name: `${firstName} ${lastName}`, role: 'hacker' }, - 'invite' - ); - if (!invite.ok || !invite.body) { - throw new Error(invite.error ?? 'Failed to generate invite link.'); - } + const titoResult = await getOrCreateTitoInvitation({ + firstName, + lastName, + email, + rsvpListSlug, + releaseIds, + }); + if (!titoResult.ok) { + throw new Error(titoResult.error); + } - if (!DEFAULT_SENDER) { - throw new Error('Email configuration missing: SENDER_EMAIL is not set.'); - } + const invite = await GenerateInvite( + { email, name: `${firstName} ${lastName}`, role: 'hacker' }, + 'invite' + ); + if (!invite.ok || !invite.body) { + throw new Error(invite.error ?? 'Failed to generate invite link.'); + } - // Step 4: Send email with both URLs - await transporter.sendMail({ - from: DEFAULT_SENDER, - to: email, - subject: HACKER_EMAIL_SUBJECT, - html: hackerInviteTemplate(firstName, titoResult.titoUrl, invite.body), - }); + const subject = + admissionType === 'waitlist_accept' + ? HACKER_WAITLIST_ACCEPT_EMAIL_SUBJECT + : HACKER_EMAIL_SUBJECT; - return { - ok: true, - titoUrl: titoResult.titoUrl, - inviteUrl: invite.body, - error: null, - }; + const html = + admissionType === 'waitlist_accept' + ? hackerWaitlistAcceptTemplate( + firstName, + titoResult.titoUrl, + invite.body + ) + : hackerInviteTemplate(firstName, titoResult.titoUrl, invite.body); + + await transporter.sendMail({ + from: DEFAULT_SENDER, + to: email, + subject, + html, + }); + + return { + ok: true, + admissionType, + titoUrl: titoResult.titoUrl, + inviteUrl: invite.body, + error: null, + }; + } else { + // Waitlist / reject: email only, no Tito, no Hub invite + const subject = + admissionType === 'waitlist' + ? HACKER_WAITLIST_EMAIL_SUBJECT + : HACKER_REJECTION_EMAIL_SUBJECT; + + const html = + admissionType === 'waitlist' + ? hackerWaitlistTemplate(firstName) + : hackerRejectionTemplate(firstName); + + await transporter.sendMail({ + from: DEFAULT_SENDER, + to: email, + subject, + html, + }); + + return { ok: true, admissionType, error: null }; + } } catch (e) { const errorMessage = e instanceof Error @@ -73,7 +129,10 @@ export default async function sendSingleHackerInvite( : typeof e === 'string' ? e : 'Unknown error'; - console.error(`[Hacker Invite] Failed (${email}):`, errorMessage); - return { ok: false, error: errorMessage }; + console.error( + `[Hacker Invite][${admissionType}] Failed (${email}):`, + errorMessage + ); + return { ok: false, admissionType, error: errorMessage }; } } diff --git a/app/(pages)/admin/_components/InvitePanel/BulkInviteForm.tsx b/app/(pages)/admin/_components/InvitePanel/BulkInviteForm.tsx index 7d4ea170..593ade76 100644 --- a/app/(pages)/admin/_components/InvitePanel/BulkInviteForm.tsx +++ b/app/(pages)/admin/_components/InvitePanel/BulkInviteForm.tsx @@ -5,7 +5,13 @@ import { parse } from 'csv-parse/sync'; import sendBulkMentorOrVolunteerInvites from '@actions/emails/sendBulkMentorOrVolunteerInvites'; import sendBulkHackerInvites from '@actions/emails/sendBulkHackerInvites'; import sendBulkJudgeHubInvites from '@actions/emails/sendBulkJudgeHubInvites'; -import { InviteData } from '@typeDefs/emails'; +import { + InviteData, + HackerAdmissionType, + HACKER_ADMISSION_TYPES, + HACKER_ADMISSION_LABELS, + admissionNeedsTitoAndHub, +} from '@typeDefs/emails'; import { Release, RsvpList } from '@typeDefs/tito'; import { buildFailureDownloadFilename, @@ -21,6 +27,7 @@ interface DisplayResult { email: string; success: boolean; error?: string; + admissionType?: HackerAdmissionType; titoUrl?: string; inviteUrl?: string; } @@ -33,8 +40,22 @@ interface DisplayBulkResponse { error: string | null; } +// Extended preview row for hackers (includes admissionType) +interface HackerPreviewRow extends InviteData { + admissionType: HackerAdmissionType; +} + +const VALID_ADMISSION_TYPES = new Set(HACKER_ADMISSION_TYPES); + +function normalizeAdmissionType(raw: string): HackerAdmissionType | null { + const normalized = raw.trim().toLowerCase().replace(/[- ]/g, '_'); + return VALID_ADMISSION_TYPES.has(normalized) + ? (normalized as HackerAdmissionType) + : null; +} + /** - * Browser-safe CSV preview parser (no Node.js deps). Full validation runs server-side. + * Browser-safe CSV preview parser for non-hacker roles (3 columns). */ function previewCSV( text: string @@ -82,6 +103,70 @@ function previewCSV( return { ok: true, rows: previewRows }; } +/** + * Browser-safe CSV preview parser for hackers (4 columns: First Name, Last Name, Email, Type). + */ +function previewHackerCSV( + text: string +): { ok: true; rows: HackerPreviewRow[] } | { ok: false; error: string } { + if (!text.trim()) return { ok: false, error: 'CSV is empty.' }; + + let parsedRows: string[][]; + try { + parsedRows = parse(text, { + trim: true, + skip_empty_lines: true, + }) as string[][]; + } catch (error) { + return { + ok: false, + error: + error instanceof Error && error.message + ? `Could not parse CSV: ${error.message}` + : 'Could not parse CSV.', + }; + } + + if (parsedRows.length === 0) return { ok: false, error: 'CSV is empty.' }; + + const firstCells = parsedRows[0].map((cell) => cell.toLowerCase()); + const hasHeader = + firstCells.some((cell) => cell.includes('first')) || + firstCells.some((cell) => cell.includes('email')); + const dataRows = hasHeader ? parsedRows.slice(1) : parsedRows; + if (dataRows.length === 0) return { ok: false, error: 'No data rows found.' }; + + const previewRows: HackerPreviewRow[] = []; + for (let i = 0; i < dataRows.length; i++) { + const cols = dataRows[i]; + const rowNum = hasHeader ? i + 2 : i + 1; + if (cols.length < 4) { + return { + ok: false, + error: `Row ${rowNum}: expected 4 columns (First Name, Last Name, Email, Type), got ${cols.length}.`, + }; + } + const admissionType = normalizeAdmissionType(cols[3] ?? ''); + if (!admissionType) { + return { + ok: false, + error: `Row ${rowNum}: "${ + cols[3] + }" is not a valid type. Must be one of: ${HACKER_ADMISSION_TYPES.join( + ', ' + )}.`, + }; + } + previewRows.push({ + firstName: cols[0], + lastName: cols[1], + email: cols[2], + admissionType, + }); + } + return { ok: true, rows: previewRows }; +} + type Status = 'idle' | 'previewing' | 'sending' | 'done'; interface Props { @@ -95,6 +180,7 @@ export default function BulkInviteForm({ rsvpLists, releases, role }: Props) { const [csvText, setCsvText] = useState(''); const [fileName, setFileName] = useState(''); const [preview, setPreview] = useState([]); + const [hackerPreview, setHackerPreview] = useState([]); const [parseError, setParseError] = useState(''); const [result, setResult] = useState(null); const [selectedListSlug, setSelectedListSlug] = useState( @@ -105,6 +191,11 @@ export default function BulkInviteForm({ rsvpLists, releases, role }: Props) { const hasTito = role !== 'judge'; + // For hackers: Tito is only required when any row needs links + const hackerRowsNeedTito = + role === 'hacker' && + hackerPreview.some((r) => admissionNeedsTitoAndHub(r.admissionType)); + const toggleRelease = (id: string) => setSelectedReleases((prev) => prev.includes(id) ? prev.filter((r) => r !== id) : [...prev, id] @@ -119,29 +210,58 @@ export default function BulkInviteForm({ rsvpLists, releases, role }: Props) { const text = ev.target?.result as string; setFileName(file.name); setCsvText(text); - const parsed = previewCSV(text); - if (parsed.ok) { - setPreview(parsed.rows); - setParseError(''); - setStatus('previewing'); + + if (role === 'hacker') { + const parsed = previewHackerCSV(text); + if (parsed.ok) { + setHackerPreview(parsed.rows); + setPreview(parsed.rows); // keep for result export + setParseError(''); + setStatus('previewing'); + } else { + setParseError(parsed.error); + setHackerPreview([]); + setPreview([]); + setStatus('idle'); + } } else { - setParseError(parsed.error); - setPreview([]); - setStatus('idle'); + 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 (hasTito && !selectedListSlug) { + if (hackerRowsNeedTito && !selectedListSlug) { + setConfigError( + 'Please select an RSVP list (required for Accept / Waitlist Accept rows).' + ); + return; + } + if (hackerRowsNeedTito && selectedReleases.length === 0) { + setConfigError( + 'Please select at least one release (required for Accept / Waitlist Accept rows).' + ); + return; + } + if (hasTito && role !== 'hacker' && !selectedListSlug) { setConfigError('Please select an RSVP list.'); return; } - if (hasTito && selectedReleases.length === 0) { + if (hasTito && role !== 'hacker' && selectedReleases.length === 0) { setConfigError('Please select at least one release.'); return; } + setConfigError(''); setStatus('sending'); setResult(null); @@ -171,6 +291,7 @@ export default function BulkInviteForm({ rsvpLists, releases, role }: Props) { email: res.email, success: res.success, error: res.error, + admissionType: res.admissionType, titoUrl: res.titoUrl, inviteUrl: res.inviteUrl, })), @@ -209,6 +330,7 @@ export default function BulkInviteForm({ rsvpLists, releases, role }: Props) { firstName: person.firstName, lastName: person.lastName, email: person.email, + admissionType: (person as HackerPreviewRow).admissionType, titoUrl: res?.titoUrl, hubUrl: res?.inviteUrl, success: res?.success ?? false, @@ -229,7 +351,6 @@ export default function BulkInviteForm({ rsvpLists, releases, role }: Props) { const handleDownloadFailuresCSV = () => { if (!result || result.failureCount === 0) return; - const csv = generateInviteFailuresCSV(preview, result.results); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); @@ -247,12 +368,16 @@ export default function BulkInviteForm({ rsvpLists, releases, role }: Props) { setCsvText(''); setFileName(''); setPreview([]); + setHackerPreview([]); setParseError(''); setResult(null); setConfigError(''); setSelectedReleases([]); }; + const previewCount = + role === 'hacker' ? hackerPreview.length : preview.length; + return (
{/* File input */} @@ -260,7 +385,9 @@ export default function BulkInviteForm({ rsvpLists, releases, role }: Props) { 0 && ( + {status === 'previewing' && previewCount > 0 && (

- {preview.length} {role} - {preview.length !== 1 ? 's' : ''} found.{' '} - {hasTito + {previewCount} {role} + {previewCount !== 1 ? 's' : ''} found.{' '} + {hasTito && (role !== 'hacker' || hackerRowsNeedTito) ? 'Configure Tito settings and review before sending:' : 'Review before sending:'}

@@ -307,33 +434,54 @@ export default function BulkInviteForm({ rsvpLists, releases, role }: Props) { Email + {role === 'hacker' && ( + + Type + + )} - {preview.map((person, i) => ( - - - {person.firstName} - - - {person.lastName} - - - {person.email} - - - ))} + {(role === 'hacker' ? hackerPreview : preview).map( + (person, i) => ( + + + {person.firstName} + + + {person.lastName} + + + {person.email} + + {role === 'hacker' && ( + + + + )} + + ) + )}
- {/* Tito config — hidden for judges */} - {hasTito && ( + {/* Tito config — shown for non-judges; for hackers only when needed */} + {hasTito && (role !== 'hacker' || hackerRowsNeedTito) && ( <> + {role === 'hacker' && ( +

+ Required for Accept and Waitlist Accept rows. Waitlist and + Reject rows send email only. +

+ )} +
)} @@ -416,7 +564,7 @@ export default function BulkInviteForm({ rsvpLists, releases, role }: Props) { {status === 'sending' && (
- Sending invites… + Sending emails…
)} @@ -460,6 +608,11 @@ export default function BulkInviteForm({ rsvpLists, releases, role }: Props) { > {r.email} + {r.admissionType && ( + + + + )} {r.error}
@@ -495,3 +648,20 @@ export default function BulkInviteForm({ rsvpLists, releases, role }: Props) { ); } + +const BADGE_STYLES: Record = { + accept: 'bg-green-100 text-green-700', + waitlist_accept: 'bg-teal-100 text-teal-700', + waitlist: 'bg-amber-100 text-amber-700', + reject: 'bg-red-100 text-red-700', +}; + +function AdmissionTypeBadge({ type }: { type: HackerAdmissionType }) { + return ( + + {HACKER_ADMISSION_LABELS[type]} + + ); +} diff --git a/app/(pages)/admin/_components/InvitePanel/InvitePanel.tsx b/app/(pages)/admin/_components/InvitePanel/InvitePanel.tsx index 54ec5961..4ec56eca 100644 --- a/app/(pages)/admin/_components/InvitePanel/InvitePanel.tsx +++ b/app/(pages)/admin/_components/InvitePanel/InvitePanel.tsx @@ -26,7 +26,7 @@ const ROLE_NOTES: Partial> = { judge: 'This template includes Judge Orientation materials.', mentor: 'This template includes Mentor Orientation materials.', hacker: - 'This template includes both a Tito e-ticket link and a HackDavis Hub registration link.', + 'Accept and Waitlist Accept send a Tito e-ticket + Hub registration invite. Waitlist and Reject send an email only.', }; const needsTito = (role: InviteRole) => role !== 'judge'; @@ -115,9 +115,20 @@ export default function InvitePanel({ role }: Props) {

Upload a CSV with columns{' '} - First Name, Last Name, Email + {role === 'hacker' + ? 'First Name, Last Name, Email, Type' + : 'First Name, Last Name, Email'} {' '} - to send invites to multiple {label.toLowerCase()}s at once. + to send emails to multiple {label.toLowerCase()}s at once. + {role === 'hacker' && ( + <> + {' '} + Valid types:{' '} + + accept, waitlist_accept, waitlist, reject + + + )}

{note &&

Note: {note}

} ([]); + const [admissionType, setAdmissionType] = + useState('accept'); const hasTito = role !== 'judge'; + const hackerNeedsLinks = + role === 'hacker' && admissionNeedsTitoAndHub(admissionType); const toggleRelease = (id: string) => setSelectedReleases((prev) => @@ -36,11 +47,23 @@ export default function SingleInviteForm({ rsvpLists, releases, role }: Props) { const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - if (hasTito && !selectedListSlug) { + if (role === 'hacker' && hackerNeedsLinks && !selectedListSlug) { setError('Please select an RSVP list.'); return; } - if (hasTito && selectedReleases.length === 0) { + if ( + role === 'hacker' && + hackerNeedsLinks && + selectedReleases.length === 0 + ) { + setError('Please select at least one release.'); + return; + } + if (hasTito && role !== 'hacker' && !selectedListSlug) { + setError('Please select an RSVP list.'); + return; + } + if (hasTito && role !== 'hacker' && selectedReleases.length === 0) { setError('Please select at least one release.'); return; } @@ -56,6 +79,7 @@ export default function SingleInviteForm({ rsvpLists, releases, role }: Props) { let result: { ok: boolean; + admissionType?: HackerAdmissionType; titoUrl?: string; inviteUrl?: string; error: string | null; @@ -68,6 +92,7 @@ export default function SingleInviteForm({ rsvpLists, releases, role }: Props) { firstName, lastName, email, + admissionType, rsvpListSlug: selectedListSlug, releaseIds: selectedReleases.join(','), }); @@ -86,7 +111,11 @@ export default function SingleInviteForm({ rsvpLists, releases, role }: Props) { setLoading(false); if (result.ok) { - setSuccessUrls({ titoUrl: result.titoUrl, inviteUrl: result.inviteUrl }); + setSuccessUrls({ + admissionType: result.admissionType, + titoUrl: result.titoUrl, + inviteUrl: result.inviteUrl, + }); (e.target as HTMLFormElement).reset(); setSelectedReleases([]); } else { @@ -96,6 +125,36 @@ export default function SingleInviteForm({ rsvpLists, releases, role }: Props) { return (
+ {/* Admission type selector — hackers only */} + {role === 'hacker' && ( +
+ +
+ {HACKER_ADMISSION_TYPES.map((type) => ( + + ))} +
+

+ {hackerNeedsLinks + ? 'Sends a Tito e-ticket + Hub registration invite.' + : 'Sends an email only — no Tito or Hub invite.'} +

+
+ )} + {/* Name + Email */}
@@ -129,8 +188,8 @@ export default function SingleInviteForm({ rsvpLists, releases, role }: Props) { />
- {/* Tito config — hidden for judges */} - {hasTito && ( + {/* Tito config — shown for non-judges; for hackers only when type needs it */} + {hasTito && (role !== 'hacker' || hackerNeedsLinks) && ( <>